fix(ui): Match PyCharm project view, matched tags and text weight

- 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
This commit is contained in:
l0ng-ai committed 2026-10-24 22:56:48 +08:00
1 parent cacddee19d
commit 046fd37064
26 files changed
+143 -36

No files matched your search

+2 -1
View File
@@ -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(),
+8 -4
View File
@@ -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<Vec<FileEntry>> {
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<Vec<FileEntry>> {
};
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<Vec<FileEntry>> {
path: path.into(),
kind,
ignored,
excluded,
target_kind,
},
));
+4 -1
View File
@@ -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);
+3 -1
View File
@@ -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`
+24 -3
View File
@@ -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<Decoration>[] {
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 }) : [],
];
+6 -1
View File
@@ -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)",
+1 -1
View File
@@ -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"]);
});
+3 -1
View File
@@ -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 (
<span
className={clsx(styles.content, cut && styles.cut, dropTarget && styles.dropTarget)}
@@ -244,7 +246,7 @@ const Row = memo(function Row({
onClick={(e) => onClick(node, e)}
onPointerDown={(e) => onPointerDown(node, e)}
>
<Icon name={icon} size={16} className={clsx(styles.icon, node.dir && styles.folderIcon, faded && styles.faded)} />
<Icon name={icon} size={16} className={clsx(styles.icon, node.dir && styles.folderIcon, dimIcon && styles.faded)} />
{isEditing && editing ? (
<InlineEditor
editing={editing}
+1
View File
@@ -50,6 +50,7 @@ function nodesFrom(arg: unknown): ProjectNode[] {
root: p === useProjectTree.getState().root,
entry: null,
ignored: false,
excluded: false,
symlink: false,
};
});
@@ -19,6 +19,7 @@ const node = (path: string, dir = false, root = false): ProjectNode => ({
root,
entry: null,
ignored: false,
excluded: false,
symlink: false,
});
@@ -21,6 +21,7 @@ const entry = (path: string, kind: "file" | "dir" = "file"): FileEntry => ({
size: 0,
mtimeMs: 1,
ignored: false,
excluded: false,
hidden: false,
});
+9 -5
View File
@@ -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<FolderRole, FolderKind> = {
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<ProjectNode, "path" | "ignored">,
node: Pick<ProjectNode, "path" | "excluded">,
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";
}
+3 -1
View File
@@ -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;
})
+1 -1
View File
@@ -13,7 +13,7 @@ import {
const entry = (path: string, kind: "file" | "dir" = "file", extra: Partial<FileEntry> = {}): 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" };
+8 -1
View File
@@ -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;
/**
@@ -11,6 +11,7 @@ const node = (path: string, dir = false): ProjectNode => ({
root: path === "/p",
entry: null,
ignored: false,
excluded: false,
symlink: false,
});
+2 -1
View File
@@ -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");
});
});
+2
View File
@@ -121,6 +121,8 @@ const SCHEME_VARS = new Set<string>([
"--bg-editor-inline",
"--fg-completion-match",
"--fg-search-match",
"--matched-brace-weight",
"--bg-matched-tag",
]);
type Vars = Map<string, string>;
+2 -1
View File
@@ -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: {
+2 -4
View File
@@ -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;
+5
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9996 8C9.79109 8 8 9.79085 8 12.0001C8 13.7674 9.14601 15.2665 10.7355 15.7955C10.9356 15.8323 11.0086 15.7088 11.0086 15.6027C11.0086 15.5077 11.0051 15.2562 11.0032 14.9225C9.89054 15.1641 9.65579 14.3862 9.65579 14.3862C9.47383 13.9241 9.21158 13.801 9.21158 13.801C8.8484 13.553 9.23908 13.5579 9.23908 13.5579C9.64057 13.5862 9.85174 13.9702 9.85174 13.9702C10.2085 14.5814 10.7881 14.4049 11.0159 14.3025C11.0523 14.0441 11.1557 13.8678 11.2698 13.7679C10.3817 13.667 9.4478 13.3237 9.4478 11.7909C9.4478 11.3543 9.60373 10.997 9.8596 10.7176C9.81835 10.6164 9.68108 10.2095 9.89889 9.65898C9.89889 9.65898 10.2346 9.55143 10.9987 10.0691C11.3177 9.98017 11.66 9.93597 12.0001 9.93425C12.34 9.93597 12.682 9.98017 13.0015 10.0691C13.7652 9.55143 14.1004 9.65898 14.1004 9.65898C14.3187 10.2095 14.1814 10.6164 14.1404 10.7176C14.3968 10.997 14.5515 11.3543 14.5515 11.7909C14.5515 13.3276 13.6161 13.6657 12.7253 13.7647C12.8687 13.8882 12.9966 14.1323 12.9966 14.5053C12.9966 15.0401 12.9917 15.4716 12.9917 15.6027C12.9917 15.7098 13.0639 15.8342 13.2667 15.7952C14.855 15.265 16 13.7669 16 12.0001C16 9.79085 14.2089 8 11.9996 8Z" fill="#161514"/>
<path d="M6.12207 2C6.39437 2.0001 6.65525 2.11111 6.84375 2.30762L8.4668 4H13C14.1046 4 15 4.89543 15 6V8C14.1643 7.37223 13.1257 7 12 7C9.23858 7 7 9.23858 7 12C7 12.7112 7.1493 13.3874 7.41699 14H2.63379C1.73186 14 1.00022 13.1645 1 12.1338V3.86621C1.00022 2.83549 1.73186 2 2.63379 2H6.12207Z" fill="#EBECF0"/>
<path d="M6.12207 2C6.39437 2.0001 6.65525 2.11111 6.84375 2.30762L8.4668 4H13C14.1046 4 15 4.89543 15 6V8C14.6922 7.7688 14.3571 7.57203 14 7.41602V6C14 5.44771 13.5523 5 13 5H8.04004L6.12207 3H2.63379C2.40532 3 2.0002 3.25829 2 3.86621V12.1338C2.0002 12.7417 2.40532 13 2.63379 13H7.10059C7.17121 13.3479 7.27821 13.6824 7.41699 14H2.63379L2.4668 13.9902C1.6979 13.9012 1.08689 13.2029 1.00879 12.3242L1 12.1338V3.86621C1.0002 2.89975 1.6431 2.10511 2.4668 2.00977L2.63379 2H6.12207Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9996 8C9.79109 8 8 9.79085 8 12.0001C8 13.7674 9.14601 15.2665 10.7355 15.7955C10.9356 15.8323 11.0086 15.7088 11.0086 15.6027C11.0086 15.5077 11.0051 15.2562 11.0032 14.9225C9.89054 15.1641 9.65579 14.3862 9.65579 14.3862C9.47383 13.9241 9.21158 13.801 9.21158 13.801C8.8484 13.553 9.23908 13.5579 9.23908 13.5579C9.64057 13.5862 9.85174 13.9702 9.85174 13.9702C10.2085 14.5814 10.7881 14.4049 11.0159 14.3025C11.0523 14.0441 11.1557 13.8678 11.2698 13.7679C10.3817 13.667 9.4478 13.3237 9.4478 11.7909C9.4478 11.3543 9.60373 10.997 9.8596 10.7176C9.81835 10.6164 9.68108 10.2095 9.89889 9.65898C9.89889 9.65898 10.2346 9.55143 10.9987 10.0691C11.3177 9.98017 11.66 9.93597 12.0001 9.93425C12.34 9.93597 12.682 9.98017 13.0015 10.0691C13.7652 9.55143 14.1004 9.65898 14.1004 9.65898C14.3187 10.2095 14.1814 10.6164 14.1404 10.7176C14.3968 10.997 14.5515 11.3543 14.5515 11.7909C14.5515 13.3276 13.6161 13.6657 12.7253 13.7647C12.8687 13.8882 12.9966 14.1323 12.9966 14.5053C12.9966 15.0401 12.9917 15.4716 12.9917 15.6027C12.9917 15.7098 13.0639 15.8342 13.2667 15.7952C14.855 15.265 16 13.7669 16 12.0001C16 9.79085 14.2089 8 11.9996 8Z" fill="#CED0D6"/>
<path d="M6.12207 2C6.39437 2.0001 6.65525 2.11111 6.84375 2.30762L8.4668 4H13C14.1046 4 15 4.89543 15 6V8C14.1643 7.37223 13.1257 7 12 7C9.23858 7 7 9.23858 7 12C7 12.7112 7.1493 13.3874 7.41699 14H2.63379C1.73186 14 1.00022 13.1645 1 12.1338V3.86621C1.00022 2.83549 1.73186 2 2.63379 2H6.12207Z" fill="#43454A"/>
<path d="M6.12207 2C6.39437 2.0001 6.65525 2.11111 6.84375 2.30762L8.4668 4H13C14.1046 4 15 4.89543 15 6V8C14.6922 7.7688 14.3571 7.57203 14 7.41602V6C14 5.44771 13.5523 5 13 5H8.04004L6.12207 3H2.63379C2.40532 3 2.0002 3.25829 2 3.86621V12.1338C2.0002 12.7417 2.40532 13 2.63379 13H7.10059C7.17121 13.3479 7.27821 13.6824 7.41699 14H2.63379L2.4668 13.9902C1.6979 13.9012 1.08689 13.2029 1.00879 12.3242L1 12.1338V3.86621C1.0002 2.89975 1.6431 2.10511 2.4668 2.00977L2.63379 2H6.12207Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+4
View File
@@ -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:
+15
View File
@@ -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("?");
});
});
+23 -9
View File
@@ -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();
}
/**