Skip to content

Commit 5fcdf31

Browse files
authored
polish sidebar navigation and project icons (#8896)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
1 parent b4c0879 commit 5fcdf31

42 files changed

Lines changed: 2047 additions & 1166 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ui/goose2/src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ pub mod git;
66
pub mod git_changes;
77
pub mod model_setup;
88
pub mod path_resolver;
9+
pub mod project_icons;
910
pub mod projects;
1011
pub mod system;
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
use base64::{engine::general_purpose, Engine as _};
2+
use std::collections::HashSet;
3+
use std::fs;
4+
use std::path::{Path, PathBuf};
5+
6+
const MAX_ICON_CANDIDATES: usize = 18;
7+
const MAX_PROJECT_ICON_BYTES: u64 = 512 * 1024;
8+
9+
#[derive(serde::Serialize, Clone)]
10+
#[serde(rename_all = "camelCase")]
11+
pub struct ProjectIconCandidate {
12+
pub id: String,
13+
pub label: String,
14+
pub icon: String,
15+
pub source_dir: String,
16+
}
17+
18+
#[derive(serde::Serialize, Clone)]
19+
#[serde(rename_all = "camelCase")]
20+
pub struct ProjectIconData {
21+
pub icon: String,
22+
}
23+
24+
struct ScoredProjectIconPath {
25+
score: i32,
26+
path: PathBuf,
27+
path_string: String,
28+
label: String,
29+
source_dir: String,
30+
group_key: String,
31+
}
32+
33+
fn is_project_icon_extension(path: &Path) -> bool {
34+
matches!(
35+
path.extension()
36+
.and_then(|ext| ext.to_str())
37+
.map(|ext| ext.to_ascii_lowercase())
38+
.as_deref(),
39+
Some("svg" | "png" | "ico" | "jpg" | "jpeg" | "webp")
40+
)
41+
}
42+
43+
fn is_ignored_icon_search_dir(root: &Path, path: &Path) -> bool {
44+
let relative_parent = path
45+
.strip_prefix(root)
46+
.unwrap_or(path)
47+
.parent()
48+
.unwrap_or_else(|| Path::new(""));
49+
50+
relative_parent.components().any(|component| {
51+
let name = component.as_os_str().to_string_lossy().to_ascii_lowercase();
52+
matches!(
53+
name.as_str(),
54+
"node_modules" | "target" | "dist" | "build" | ".git" | ".next" | ".turbo"
55+
)
56+
})
57+
}
58+
59+
fn is_generated_icon_variant(path: &Path) -> bool {
60+
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
61+
return false;
62+
};
63+
let normalized = file_name.to_ascii_lowercase();
64+
let stem = path
65+
.file_stem()
66+
.and_then(|name| name.to_str())
67+
.unwrap_or_default()
68+
.to_ascii_lowercase();
69+
let mostly_size_token = stem
70+
.chars()
71+
.all(|c| c.is_ascii_digit() || matches!(c, 'x' | '@' | '-' | '_'));
72+
73+
normalized.starts_with("appicon-")
74+
|| normalized.starts_with("square")
75+
|| normalized.starts_with("storelogo")
76+
|| normalized.contains("template")
77+
|| normalized.contains("@2x")
78+
|| normalized.contains("@3x")
79+
|| mostly_size_token
80+
|| stem
81+
.strip_prefix("icon-")
82+
.is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
83+
|| stem
84+
.strip_prefix("icon@")
85+
.is_some_and(|suffix| suffix.ends_with('x'))
86+
}
87+
88+
fn is_likely_project_icon(path: &Path) -> bool {
89+
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
90+
return false;
91+
};
92+
let normalized = file_name.to_ascii_lowercase();
93+
normalized == "favicon.ico"
94+
|| normalized == "favicon.svg"
95+
|| normalized == "favicon.png"
96+
|| normalized.starts_with("apple-touch-icon")
97+
|| normalized.starts_with("mstile-")
98+
|| normalized.contains("logo")
99+
|| normalized.contains("brand")
100+
|| normalized.contains("wordmark")
101+
|| normalized.contains("app-icon")
102+
|| normalized.contains("appicon")
103+
|| normalized.contains("icon")
104+
}
105+
106+
fn project_icon_score(root: &Path, path: &Path) -> i32 {
107+
let file_name = path
108+
.file_name()
109+
.and_then(|name| name.to_str())
110+
.unwrap_or_default()
111+
.to_ascii_lowercase();
112+
let relative = path
113+
.strip_prefix(root)
114+
.unwrap_or(path)
115+
.to_string_lossy()
116+
.to_ascii_lowercase();
117+
118+
let mut score = 100;
119+
if file_name.starts_with("favicon") {
120+
score -= 35;
121+
}
122+
if file_name.contains("logo") {
123+
score -= 30;
124+
}
125+
if file_name.contains("brand") || file_name.contains("wordmark") {
126+
score -= 25;
127+
}
128+
if relative.starts_with("public/")
129+
|| relative.starts_with("static/")
130+
|| relative.starts_with("assets/")
131+
|| relative.starts_with("src/assets/")
132+
|| relative.starts_with("src/images/")
133+
{
134+
score -= 20;
135+
}
136+
score + relative.matches('/').count() as i32
137+
}
138+
139+
fn project_icon_group_key(path: &Path) -> String {
140+
let file_stem = path
141+
.file_stem()
142+
.and_then(|name| name.to_str())
143+
.unwrap_or_default()
144+
.to_ascii_lowercase();
145+
let normalized = file_stem
146+
.replace("goose-logo", "logo")
147+
.replace("logo-codename-goose", "logo")
148+
.replace("codename-goose", "logo");
149+
150+
if normalized.contains("favicon") {
151+
"favicon".to_string()
152+
} else if normalized.contains("wordmark") {
153+
"wordmark".to_string()
154+
} else if normalized.contains("brand") {
155+
"brand".to_string()
156+
} else if normalized.contains("logo") {
157+
"logo".to_string()
158+
} else if normalized.contains("app-icon") || normalized.contains("appicon") {
159+
"app-icon".to_string()
160+
} else {
161+
normalized
162+
}
163+
}
164+
165+
fn project_icon_root_key(root: &Path) -> String {
166+
root.to_string_lossy().into_owned()
167+
}
168+
169+
fn project_icon_candidate_group_key(root: &Path, path: &Path) -> String {
170+
format!(
171+
"{}:{}",
172+
project_icon_root_key(root),
173+
project_icon_group_key(path)
174+
)
175+
}
176+
177+
fn read_project_icon_data_url(path: &Path) -> Result<String, String> {
178+
let metadata = fs::metadata(path).map_err(|e| format!("Failed to inspect icon: {}", e))?;
179+
if !metadata.is_file() {
180+
return Err("Icon path is not a file".to_string());
181+
}
182+
if metadata.len() > MAX_PROJECT_ICON_BYTES {
183+
return Err("Icon file is too large".to_string());
184+
}
185+
186+
let mime = mime_guess::from_path(path)
187+
.first_or_octet_stream()
188+
.essence_str()
189+
.to_string();
190+
if !matches!(
191+
mime.as_str(),
192+
"image/svg+xml"
193+
| "image/png"
194+
| "image/x-icon"
195+
| "image/vnd.microsoft.icon"
196+
| "image/jpeg"
197+
| "image/webp"
198+
) {
199+
return Err("Icon file type is not supported".to_string());
200+
}
201+
202+
let bytes = fs::read(path).map_err(|e| format!("Failed to read icon: {}", e))?;
203+
Ok(format!(
204+
"data:{};base64,{}",
205+
mime,
206+
general_purpose::STANDARD.encode(bytes)
207+
))
208+
}
209+
210+
#[tauri::command]
211+
pub fn scan_project_icons(working_dirs: Vec<String>) -> Result<Vec<ProjectIconCandidate>, String> {
212+
let mut candidates: Vec<ScoredProjectIconPath> = Vec::new();
213+
let mut seen = HashSet::new();
214+
215+
for dir in working_dirs {
216+
let root = PathBuf::from(dir.trim());
217+
if !root.is_dir() {
218+
continue;
219+
}
220+
221+
let source_dir = root
222+
.file_name()
223+
.and_then(|name| name.to_str())
224+
.unwrap_or("project")
225+
.to_string();
226+
227+
let walker = ignore::WalkBuilder::new(&root)
228+
.max_depth(Some(6))
229+
.standard_filters(true)
230+
.build();
231+
232+
for entry in walker.flatten() {
233+
let path = entry.path();
234+
if !path.is_file()
235+
|| is_ignored_icon_search_dir(&root, path)
236+
|| is_generated_icon_variant(path)
237+
|| !is_project_icon_extension(path)
238+
|| !is_likely_project_icon(path)
239+
{
240+
continue;
241+
}
242+
243+
let path_string = path.to_string_lossy().into_owned();
244+
if !seen.insert(path_string.clone()) {
245+
continue;
246+
}
247+
248+
let relative = path.strip_prefix(&root).unwrap_or(path);
249+
let label = relative.to_string_lossy().into_owned();
250+
let score = project_icon_score(&root, path);
251+
let group_key = project_icon_candidate_group_key(&root, path);
252+
candidates.push(ScoredProjectIconPath {
253+
score,
254+
path: path.to_path_buf(),
255+
path_string,
256+
label,
257+
source_dir: source_dir.clone(),
258+
group_key,
259+
});
260+
}
261+
}
262+
263+
candidates.sort_by(|a, b| a.score.cmp(&b.score).then_with(|| a.label.cmp(&b.label)));
264+
265+
let mut seen_groups = HashSet::new();
266+
let mut icons = Vec::new();
267+
for candidate in candidates {
268+
if icons.len() >= MAX_ICON_CANDIDATES {
269+
break;
270+
}
271+
if seen_groups.contains(&candidate.group_key) {
272+
continue;
273+
}
274+
let icon = match read_project_icon_data_url(&candidate.path) {
275+
Ok(icon) => icon,
276+
Err(_) => continue,
277+
};
278+
seen_groups.insert(candidate.group_key);
279+
icons.push(ProjectIconCandidate {
280+
id: candidate.path_string.clone(),
281+
label: candidate.label,
282+
icon,
283+
source_dir: candidate.source_dir,
284+
});
285+
}
286+
287+
Ok(icons)
288+
}
289+
290+
#[tauri::command]
291+
pub fn read_project_icon(path: String) -> Result<ProjectIconData, String> {
292+
let path = PathBuf::from(path.trim());
293+
if !is_project_icon_extension(&path) {
294+
return Err("Icon file type is not supported".to_string());
295+
}
296+
let icon = read_project_icon_data_url(&path)?;
297+
Ok(ProjectIconData { icon })
298+
}
299+
300+
#[cfg(test)]
301+
mod tests {
302+
use super::*;
303+
304+
#[test]
305+
fn ignored_icon_search_dirs_do_not_match_root_ancestors() {
306+
let root = Path::new("/Users/alice/build/myapp");
307+
let icon = root.join("public/logo.svg");
308+
309+
assert!(!is_ignored_icon_search_dir(root, &icon));
310+
}
311+
312+
#[test]
313+
fn ignored_icon_search_dirs_match_descendant_dirs() {
314+
let root = Path::new("/Users/alice/projects/myapp");
315+
let icon = root.join("dist/logo.svg");
316+
317+
assert!(is_ignored_icon_search_dir(root, &icon));
318+
}
319+
320+
#[test]
321+
fn project_icon_group_keys_distinguish_roots_with_same_basename() {
322+
let first_root = Path::new("/work/client");
323+
let second_root = Path::new("/archive/client");
324+
let first_icon = first_root.join("public/logo.svg");
325+
let second_icon = second_root.join("public/logo.svg");
326+
327+
assert_ne!(
328+
project_icon_candidate_group_key(first_root, &first_icon),
329+
project_icon_candidate_group_key(second_root, &second_icon)
330+
);
331+
}
332+
}

ui/goose2/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub fn run() {
5252
commands::projects::list_archived_projects,
5353
commands::projects::archive_project,
5454
commands::projects::restore_project,
55+
commands::project_icons::scan_project_icons,
56+
commands::project_icons::read_project_icon,
5557
commands::doctor::run_doctor,
5658
commands::doctor::run_doctor_fix,
5759
commands::git::get_git_state,

0 commit comments

Comments
 (0)