From 046fd370647845136a14d3d3e65b26e98ebfd3d8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:56:48 +0800 Subject: [PATCH] fix(ui): Match PyCharm project view, matched tags and text weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project widget initials follow IDEA's AvatarUtils.initials ("idea" → "I"). - "Show directories with changed descendants" is off by default, as in IDEA. - FileEntry gains `excluded`: only excluded folders get the excluded icon and dimming; git-ignored ones keep the plain icon and the ignored colour. - `.github` folders get IDEA's GitHub folder icon. - Matched tag names are never bold and use their own background (MATCHED_TAG_NAME); matched braces are bold in dark schemes only. - Revert Greyscale antialiasing to `antialiased` for UI and editor; `auto` rendered text heavier than PyCharm. Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv --- crates/ide-core/src/workflow_settings.rs | 3 +- crates/ide-vfs/src/lib.rs | 12 ++++--- crates/ide-vfs/tests/vfs.rs | 5 ++- src/bindings.ts | 4 ++- src/editor/core/cm/setup.ts | 27 ++++++++++++++-- src/editor/core/cm/theme.ts | 7 +++- src/editor/core/view/navBar.test.ts | 2 +- src/features/project-tree/ProjectView.tsx | 4 ++- src/features/project-tree/actions.ts | 1 + src/features/project-tree/contextMenu.test.ts | 1 + src/features/project-tree/extraRoots.test.ts | 1 + src/features/project-tree/folderKind.ts | 14 +++++--- src/features/project-tree/fs.ts | 4 ++- src/features/project-tree/model.test.ts | 2 +- src/features/project-tree/model.ts | 9 +++++- src/features/project-tree/operations.test.ts | 1 + src/features/project-tree/roots.test.ts | 3 +- src/platform/colorScheme.ts | 2 ++ src/platform/workflowSettings.ts | 3 +- src/styles/global.css | 6 ++-- src/styles/tokens.css | 5 +++ src/ui/expui/nodes/folderGithub.svg | 6 ++++ src/ui/expui/nodes/folderGithub_dark.svg | 6 ++++ src/ui/icons.tsx | 4 +++ src/workbench/projectColor.test.ts | 15 +++++++++ src/workbench/projectColor.ts | 32 +++++++++++++------ 26 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 src/ui/expui/nodes/folderGithub.svg create mode 100644 src/ui/expui/nodes/folderGithub_dark.svg create mode 100644 src/workbench/projectColor.test.ts diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 027712de3..c574b84fb 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -24,7 +24,8 @@ pub struct VcsSettings { impl Default for VcsSettings { fn default() -> Self { Self { - show_dirty_directories: true, + // IDEA's VcsConfiguration.SHOW_DIRTY_RECURSIVELY is off by default. + show_dirty_directories: false, limit_history: true, history_limit: 1000, git: GitSettings::default(), diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index bb495cd00..ee528ac46 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -37,8 +37,10 @@ pub struct FileEntry { pub size: u64, /// Milliseconds since the Unix epoch. pub mtime_ms: f64, - /// Matched by .gitignore / exclude settings (shown dimmed, like IDEA "excluded"). + /// Matched by .gitignore or the exclude settings. pub ignored: bool, + /// Matched by the exclude settings (IDEA's "excluded": dimmed, excluded-folder icon). + pub excluded: bool, pub hidden: bool, /// For symlinks: what the link points to (`None` when dangling); `None` /// for other entries. @@ -96,8 +98,8 @@ pub fn natural_cmp(a: &str, b: &str) -> Ordering { /// case-insensitively with natural number ordering. /// /// Symlinks are reported as [`EntryKind::Symlink`] but sorted (and sized) by -/// what they point to. `ignored` reflects `excludes` (relative to the enclosing -/// repository root, else to `dir`) and the applicable `.gitignore`/`.ignore` files. +/// what they point to. `excluded` reflects `excludes` (relative to the enclosing +/// repository root, else to `dir`); `ignored` also the applicable `.gitignore`/`.ignore` files. pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult> { if !fs::metadata(dir)?.is_dir() { return Err(IdeError::invalid(format!("{} is not a directory", dir.display()))); @@ -133,7 +135,8 @@ pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult> { }; let is_dir = meta.is_dir(); let rel = path.strip_prefix(&base).unwrap_or(&path); - let ignored = excludes.is_excluded(rel) || gitignore.is_ignored(&path, is_dir); + let excluded = excludes.is_excluded(rel); + let ignored = excluded || gitignore.is_ignored(&path, is_dir); entries.push(( is_dir, FileEntry { @@ -144,6 +147,7 @@ pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult> { path: path.into(), kind, ignored, + excluded, target_kind, }, )); diff --git a/crates/ide-vfs/tests/vfs.rs b/crates/ide-vfs/tests/vfs.rs index 5ca201172..80e66c71a 100644 --- a/crates/ide-vfs/tests/vfs.rs +++ b/crates/ide-vfs/tests/vfs.rs @@ -71,6 +71,9 @@ fn read_dir_sorting_and_flags() { assert!(get("build").ignored); assert!(get("a.log").ignored); assert!(get("node_modules").ignored); + // Excluded (settings) vs only git-ignored, like IDEA's excluded folders. + assert!(get("node_modules").excluded && get(".git").excluded); + assert!(!get("build").excluded && !get("a.log").excluded); assert!(!get("src").ignored && !get("B.md").ignored && !get(".gitignore").ignored); assert_eq!(get("src").kind, EntryKind::Dir); assert_eq!(get("B.md").kind, EntryKind::File); @@ -99,7 +102,7 @@ fn read_dir_sorting_and_flags() { assert_eq!(ignored, BTreeSet::from(["local.txt", "other.log"])); let nm = read_dir(&r.join("node_modules"), &excludes).unwrap(); - assert!(nm.iter().all(|e| e.ignored), "children of excluded dirs are excluded"); + assert!(nm.iter().all(|e| e.ignored && e.excluded), "children of excluded dirs are excluded"); assert_eq!(read_dir(&r.join("B.md"), &excludes).unwrap_err().kind, ErrorKind::InvalidInput); assert_eq!(read_dir(&r.join("missing"), &excludes).unwrap_err().kind, ErrorKind::NotFound); diff --git a/src/bindings.ts b/src/bindings.ts index e8799cd05..61a44c4aa 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1847,8 +1847,10 @@ export type FileEntry = { size: number, /** Milliseconds since the Unix epoch. */ mtimeMs: number | null, - /** Matched by .gitignore / exclude settings (shown dimmed, like IDEA "excluded"). */ + /** Matched by .gitignore or the exclude settings. */ ignored: boolean, + /** Matched by the exclude settings (IDEA's "excluded": dimmed, excluded-folder icon). */ + excluded: boolean, hidden: boolean, /** * For symlinks: what the link points to (`None` when dangling); `None` diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index e5618dde9..b1a6f2a90 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -2,8 +2,9 @@ * The base extension set of every text editor, with compartments for the * parts that change at runtime (language, settings, contributions). */ -import { Compartment, EditorState, Prec, type Extension } from "@codemirror/state"; +import { Compartment, EditorState, Prec, type Extension, type Range } from "@codemirror/state"; import { + Decoration, EditorView, dropCursor, highlightActiveLineGutter, @@ -11,7 +12,14 @@ import { keymap, lineNumbers, } from "@codemirror/view"; -import { bracketMatching, indentOnInput, indentUnit, syntaxHighlighting, type LanguageSupport } from "@codemirror/language"; +import { + bracketMatching, + indentOnInput, + indentUnit, + syntaxHighlighting, + type LanguageSupport, + type MatchResult, +} from "@codemirror/language"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete"; import { highlightSelectionMatches } from "@codemirror/search"; @@ -172,11 +180,24 @@ export function stickyExtension(settings: EditorSettings | undefined): Extension return settings?.stickyScroll === false || isDistractionFree() ? [] : [stickyScroll, stickyMaxLines.of(prefsOf(settings).codeEditing.stickyLinesMax)]; } +const matchingBracket = Decoration.mark({ class: "cm-matchingBracket" }); +const matchingTag = Decoration.mark({ class: "cm-matchingBracket cm-matchingTag" }); +const nonmatchingBracket = Decoration.mark({ class: "cm-nonmatchingBracket" }); + +/** Like the default, but matched tag names (HTML / XML / JSX) get their own mark, as IDEA's MATCHED_TAG_NAME. */ +function renderMatch(match: MatchResult, state: EditorState): readonly Range[] { + const mark = (from: number, to: number) => + !match.matched ? nonmatchingBracket : /^[()[\]{}]$/.test(state.sliceDoc(from, to)) ? matchingBracket : matchingTag; + const out = [mark(match.start.from, match.start.to).range(match.start.from, match.start.to)]; + if (match.end) out.push(mark(match.end.from, match.end.to).range(match.end.from, match.end.to)); + return out; +} + export function heavyExtension(docLength: number, settings?: EditorSettings): Extension { if (docLength > HEAVY_LIMIT) return []; const prefs = prefsOf(settings); return [ - prefs.codeEditing.highlightBraces ? bracketMatching() : [], + prefs.codeEditing.highlightBraces ? bracketMatching({ renderMatch }) : [], prefs.appearance.indentGuides ? indentGuides : [], prefs.codeEditing.highlightSelection ? highlightSelectionMatches({ minSelectionLength: 2, maxMatches: 500 }) : [], ]; diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 3939fa91a..8e75a9d9c 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -67,12 +67,17 @@ export const openideaTheme = EditorView.theme({ }, ".cm-activeLine": { backgroundColor: "var(--editor-caret-row)" }, ".cm-selectionMatch": { backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent))" }, + // IDEA's MATCHED_BRACE_ATTRIBUTES (bold in dark schemes only) and MATCHED_TAG_NAME (never bold). ".cm-matchingBracket, &.cm-focused .cm-matchingBracket": { backgroundColor: "var(--editor-brace)", color: "inherit", - fontWeight: "700", + fontWeight: "var(--matched-brace-weight, 700)", borderRadius: "1px", }, + ".cm-matchingTag, &.cm-focused .cm-matchingTag": { + backgroundColor: "var(--bg-matched-tag, var(--editor-brace))", + fontWeight: "inherit", + }, ".cm-nonmatchingBracket, &.cm-focused .cm-nonmatchingBracket": { color: "var(--danger)", backgroundColor: "color-mix(in srgb, var(--danger) 14%, transparent)", diff --git a/src/editor/core/view/navBar.test.ts b/src/editor/core/view/navBar.test.ts index 3395f2aa6..ab5d7ebf4 100644 --- a/src/editor/core/view/navBar.test.ts +++ b/src/editor/core/view/navBar.test.ts @@ -17,7 +17,7 @@ describe("navigation bar popup model", () => { }); it("lists folder children without IDE-hidden names, and symbol children", () => { - const e = (name: string, kind: FileEntry["kind"]) => ({ name, path: `/p/${name}`, kind, size: 0, mtimeMs: 0, ignored: false, hidden: false }) as FileEntry; + const e = (name: string, kind: FileEntry["kind"]) => ({ name, path: `/p/${name}`, kind, size: 0, mtimeMs: 0, ignored: false, excluded: false, hidden: false }) as FileEntry; expect(dirChildren([e(".git", "dir"), e("src", "dir"), e("a.ts", "file")]).map((c) => `${c.kind}:${c.label}`)).toEqual(["dir:src", "file:a.ts"]); expect(symbolChildren("/p/a.ts", [sym("A", [sym("b")])]).map((c) => c.label)).toEqual(["A"]); }); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 45c25b19a..26c2875a4 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -234,6 +234,8 @@ const Row = memo(function Row({ const isEditing = !!editing && (node.placeholder ? editing.kind === "new" : editing.kind === "rename" && editing.key === node.key); const faded = node.ignored || deco.faded; + // IDEA dims the icon of excluded folders only; git-ignored ones just get the ignored colour. + const dimIcon = node.excluded; return ( onClick(node, e)} onPointerDown={(e) => onPointerDown(node, e)} > - + {isEditing && editing ? ( ({ root, entry: null, ignored: false, + excluded: false, symlink: false, }); diff --git a/src/features/project-tree/extraRoots.test.ts b/src/features/project-tree/extraRoots.test.ts index 893027c44..d8a03e819 100644 --- a/src/features/project-tree/extraRoots.test.ts +++ b/src/features/project-tree/extraRoots.test.ts @@ -21,6 +21,7 @@ const entry = (path: string, kind: "file" | "dir" = "file"): FileEntry => ({ size: 0, mtimeMs: 1, ignored: false, + excluded: false, hidden: false, }); diff --git a/src/features/project-tree/folderKind.ts b/src/features/project-tree/folderKind.ts index b49314ba4..194238ee0 100644 --- a/src/features/project-tree/folderKind.ts +++ b/src/features/project-tree/folderKind.ts @@ -1,6 +1,6 @@ /** Folder icon kind in the project view, from the effective roots. */ import { rootRole, type FolderRole, type RootFolder } from "@/platform/languageSettings"; -import { relativePath } from "@/platform/project"; +import { basename, relativePath } from "@/platform/project"; import type { FolderKind } from "@/ui"; import type { ProjectNode } from "./model"; @@ -12,13 +12,17 @@ const ROLE_KIND: Record = { excluded: "excluded", }; -/** Project Structure marks and roots detected from build files; ignored entries look excluded. */ +/** + * Project Structure marks and roots detected from build files. Like IDEA, only + * excluded folders get the excluded icon; git-ignored ones keep the plain one. + */ export function folderKind( - node: Pick, + node: Pick, roots: readonly RootFolder[], root: string | null, ): FolderKind { - if (node.ignored) return "excluded"; + if (node.excluded) return "excluded"; const role = root ? rootRole(roots, relativePath(root, node.path)) : null; - return role ? ROLE_KIND[role] : "plain"; + if (role) return ROLE_KIND[role]; + return basename(node.path) === ".github" ? "github" : "plain"; } diff --git a/src/features/project-tree/fs.ts b/src/features/project-tree/fs.ts index aa0c9bb50..7e9bf13ed 100644 --- a/src/features/project-tree/fs.ts +++ b/src/features/project-tree/fs.ts @@ -78,13 +78,15 @@ export class MemoryFs implements ProjectFs { .map((p) => { const c = this.nodes.get(p)!; const name = nameOf(p); + const excluded = [...this.ignored].some((i) => p === i || isInside(p, i)); return { name, path: p, kind: c.kind, size: c.kind === "file" ? c.size : 0, mtimeMs: 0, - ignored: [...this.ignored].some((i) => p === i || isInside(p, i)), + ignored: excluded, + excluded, hidden: name.startsWith("."), } satisfies FileEntry; }) diff --git a/src/features/project-tree/model.test.ts b/src/features/project-tree/model.test.ts index 1a6d6d2a7..1ae5aa79d 100644 --- a/src/features/project-tree/model.test.ts +++ b/src/features/project-tree/model.test.ts @@ -13,7 +13,7 @@ import { const entry = (path: string, kind: "file" | "dir" = "file", extra: Partial = {}): FileEntry => { const name = path.slice(path.lastIndexOf("/") + 1); - return { name, path, kind, size: 0, mtimeMs: 1, ignored: false, hidden: name.startsWith("."), ...extra }; + return { name, path, kind, size: 0, mtimeMs: 1, ignored: false, excluded: false, hidden: name.startsWith("."), ...extra }; }; const ROOT = { path: "/r", name: "acme" }; diff --git a/src/features/project-tree/model.ts b/src/features/project-tree/model.ts index c3e6d86f0..198b3727e 100644 --- a/src/features/project-tree/model.ts +++ b/src/features/project-tree/model.ts @@ -36,6 +36,8 @@ export interface ProjectNode { root: boolean; entry: FileEntry | null; ignored: boolean; + /** Excluded by the exclude settings / Project Structure (not just git-ignored). */ + excluded: boolean; symlink: boolean; /** Placeholder row for the inline "new file / folder" editor. */ placeholder?: "file" | "dir"; @@ -120,6 +122,7 @@ export function buildModel( root: true, entry: null, ignored: false, + excluded: false, symlink: false, }; byKey.set(rootNode.key, rootNode); @@ -137,6 +140,7 @@ export function buildModel( root: false, entry, ignored: entry.ignored, + excluded: entry.excluded, symlink: entry.kind === "symlink", }; } @@ -151,7 +155,7 @@ export function buildModel( const visible = visibleEntries(listing, opts); if (visible.length !== 1) break; const only = visible[0]!; - if (!isDirEntry(only) || only.ignored !== entry.ignored) break; + if (!isDirEntry(only) || only.ignored !== entry.ignored || only.excluded !== entry.excluded) break; if (placeholder && placeholder.parent === only.path) break; end = only; chain.push(only.path); @@ -168,6 +172,7 @@ export function buildModel( root: false, entry: end, ignored: entry.ignored, + excluded: entry.excluded, symlink: entry.kind === "symlink", }; }; @@ -186,6 +191,7 @@ export function buildModel( root: false, entry: null, ignored: false, + excluded: false, symlink: false, placeholder: placeholder.kind, }; @@ -312,6 +318,7 @@ const sameEntry = (a: FileEntry, b: FileEntry) => a.size === b.size && a.mtimeMs === b.mtimeMs && a.ignored === b.ignored && + a.excluded === b.excluded && a.hidden === b.hidden; /** diff --git a/src/features/project-tree/operations.test.ts b/src/features/project-tree/operations.test.ts index b54d4ae96..b4c7887b4 100644 --- a/src/features/project-tree/operations.test.ts +++ b/src/features/project-tree/operations.test.ts @@ -11,6 +11,7 @@ const node = (path: string, dir = false): ProjectNode => ({ root: path === "/p", entry: null, ignored: false, + excluded: false, symlink: false, }); diff --git a/src/features/project-tree/roots.test.ts b/src/features/project-tree/roots.test.ts index d650f0642..e9b37e92b 100644 --- a/src/features/project-tree/roots.test.ts +++ b/src/features/project-tree/roots.test.ts @@ -26,13 +26,14 @@ const MAVEN: RootFolder[] = [ describe("folder icons", () => { it("follow detected and marked roots", () => { - const kind = (rel: string, ignored = false) => folderKind({ path: `/p/${rel}`, ignored }, MAVEN, "/p"); + const kind = (rel: string, excluded = false) => folderKind({ path: `/p/${rel}`, excluded }, MAVEN, "/p"); expect(kind("src/main/java")).toBe("source"); expect(kind("src/test/java")).toBe("test"); expect(kind("src/test/resources")).toBe("testResources"); expect(kind("target")).toBe("excluded"); expect(kind("src")).toBe("plain"); expect(kind("docs", true)).toBe("excluded"); + expect(kind(".github")).toBe("github"); }); }); diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 6b2add77f..d4ab24572 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -121,6 +121,8 @@ const SCHEME_VARS = new Set([ "--bg-editor-inline", "--fg-completion-match", "--fg-search-match", + "--matched-brace-weight", + "--bg-matched-tag", ]); type Vars = Map; diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 9e2740c75..0c0760c0a 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -156,7 +156,8 @@ export const DEFAULT_EXTERNAL_TOOL: ExternalToolValid = { export const DEFAULT_WORKFLOW: WorkflowSettings = { vcs: { - showDirtyDirectories: true, + // IDEA's VcsConfiguration.SHOW_DIRTY_RECURSIVELY is off by default. + showDirtyDirectories: false, limitHistory: true, historyLimit: 1000, git: { diff --git a/src/styles/global.css b/src/styles/global.css index c45ece377..0be0ee87c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -26,8 +26,7 @@ body { font-family: var(--font-ui); font-size: var(--fs-ui); line-height: 1.4; - /* Greyscale: `auto` keeps macOS stem darkening, as JetBrains IDEs render text. */ - -webkit-font-smoothing: auto; + -webkit-font-smoothing: antialiased; overflow: hidden; -webkit-user-select: none; user-select: none; @@ -135,9 +134,8 @@ a:hover { :root[data-aa-ide="none"] body { -webkit-font-smoothing: none; } -/* `antialiased` drops stem darkening: glyphs and syntax colours render lighter than PyCharm's. */ :root[data-aa-editor="greyscale"] .cm-editor { - -webkit-font-smoothing: auto; + -webkit-font-smoothing: antialiased; } :root[data-aa-editor="subpixel"] .cm-editor { -webkit-font-smoothing: subpixel-antialiased; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 08bbfb884..829e1fd97 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -121,6 +121,8 @@ --bg-text-search-result: #114957; --bg-text-search-result-current: #165e70; --bg-matched-brace: #43454a; + --matched-brace-weight: 700; + --bg-matched-tag: #3f4045; --bg-fold-placeholder: #393b40; --fg-fold-placeholder: #868991; --fg-fold-marker: #6f737a; @@ -444,6 +446,8 @@ --bg-text-search-result: #fcd47e; --bg-text-search-result-current: #f2bf57; --bg-matched-brace: #93d9d9; + --matched-brace-weight: normal; + --bg-matched-tag: #c9ecec; --bg-fold-placeholder: #e9f5e6; --fg-fold-placeholder: #414d41; --fg-fold-marker: #8b8e94; @@ -661,6 +665,7 @@ --bg-text-search-result: #146a7d; --bg-text-search-result-current: #1d8aa3; --bg-matched-brace: #55585f; + --bg-matched-tag: #45474d; --bg-fold-placeholder: #33353b; --fg-fold-placeholder: #ccced3; --fg-fold-marker: #9fa2a9; diff --git a/src/ui/expui/nodes/folderGithub.svg b/src/ui/expui/nodes/folderGithub.svg new file mode 100644 index 000000000..18c2e77e8 --- /dev/null +++ b/src/ui/expui/nodes/folderGithub.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/folderGithub_dark.svg b/src/ui/expui/nodes/folderGithub_dark.svg new file mode 100644 index 000000000..2b51541e6 --- /dev/null +++ b/src/ui/expui/nodes/folderGithub_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index fe0f2851e..40651ed38 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -39,6 +39,7 @@ const ICONS = { "folder-test-resources": "openidea/nodes/testResourcesRoot", "folder-excluded": "nodes/excludeRoot", "folder-library": "nodes/libraryFolder", + "folder-github": "nodes/folderGithub", module: "nodes/module", "spring-boot": "openidea/runConfigurations/springBoot", file: "fileTypes/text", @@ -807,6 +808,7 @@ export type FolderKind = | "testResources" | "excluded" | "library" + | "github" | "module"; /** Folder icon by role (expui has no separate "open" folder glyph). */ @@ -824,6 +826,8 @@ export function folderIconName(kind: FolderKind = "plain"): IconName { return "folder-excluded"; case "library": return "folder-library"; + case "github": + return "folder-github"; case "module": return "module"; default: diff --git a/src/workbench/projectColor.test.ts b/src/workbench/projectColor.test.ts new file mode 100644 index 000000000..b9cc0643c --- /dev/null +++ b/src/workbench/projectColor.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { projectInitials } from "./projectColor"; + +describe("projectInitials", () => { + it("follows IDEA's AvatarUtils.initials", () => { + expect(projectInitials("idea")).toBe("I"); + expect(projectInitials("MyProject")).toBe("MP"); + expect(projectInitials("OpenIDEA")).toBe("OI"); + expect(projectInitials("py-demo")).toBe("PD"); + expect(projectInitials("web demo")).toBe("WD"); + expect(projectInitials("a.b.c")).toBe("AC"); + expect(projectInitials("123abc")).toBe("A"); + expect(projectInitials("")).toBe("?"); + }); +}); diff --git a/src/workbench/projectColor.ts b/src/workbench/projectColor.ts index 366538d17..74e36c18d 100644 --- a/src/workbench/projectColor.ts +++ b/src/workbench/projectColor.ts @@ -1,6 +1,6 @@ /** * IDEA-style project identity: a colour slot (1–9, like RecentProject.ColorN) - * derived from the project name, and the two-letter initials shown in the + * derived from the project name, and the initials shown in the * title bar's project widget. */ @@ -13,15 +13,29 @@ export function projectColorIndex(name: string): number { return (Math.abs(h) % PROJECT_COLOR_COUNT) + 1; } -/** "py-demo" → "PD", "openidea" → "OP", "MyProject" → "MP". */ +/** + * IDEA's AvatarUtils.initials: two camel-case capitals, else the first letters + * of the first and last words, else one letter. "py-demo" → "PD", + * "MyProject" → "MP", "idea" → "I". + */ export function projectInitials(name: string): string { - const words = name - .replace(/([a-z])([A-Z])/g, "$1 $2") - .split(/[^\p{L}\p{N}]+/u) - .filter(Boolean); - if (words.length === 0) return "?"; - if (words.length === 1) return [...words[0]!].slice(0, 2).join("").toUpperCase(); - return (words[0]![0]! + words[1]![0]!).toUpperCase(); + const text = name.trim(); + const camel = camelInitials(text); + if (camel.length === 2) return camel; + for (const separators of [" ", ",", "-", "_", ".", "`'\""]) { + const words = text.split(new RegExp(`[${separators}]`)).filter(Boolean); + if (words.length >= 2) return ([...words[0]!][0]! + [...words.at(-1)!][0]!).toUpperCase(); + } + return camel || "?"; +} + +/** First letter plus the next capital of the leading word: "MyProject" → "MP", "idea" → "I". */ +function camelInitials(text: string): string { + const word = /\p{L}[\p{L}\p{N}]*/u.exec(text)?.[0] ?? ""; + const [first, ...rest] = [...word]; + if (!first) return ""; + const capital = rest.find((c) => c !== c.toLowerCase()); + return (first + (capital ?? "")).toUpperCase(); } /**