mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
1 parent
cacddee19d
commit
046fd37064
26 files changed
+143
-36
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
));
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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 }) : [],
|
||||
];
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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:
|
||||
|
||||
@@ -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("?");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user