Файли
openidea/src/features/project-tree/fs.ts
T
l0ng-ai 046fd37064 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
2026-10-24 22:56:48 +08:00

194 рядки
7.3 KiB
TypeScript

/**
* File system access for the project view. Uses the backend in the desktop
* app; plain-browser development (and tests) use an in-memory file system.
*/
import type { FileEntry } from "@/bindings";
import { notifyFileOperation } from "@/platform/fileOps";
import { call, commands, emitLocalIdeEvent, isTauri, IpcError } from "@/platform/ipc";
import { isInside, joinPath, nameOf, parentOf } from "./paths";
export interface ProjectFs {
readDir(path: string): Promise<FileEntry[]>;
createFile(path: string): Promise<void>;
createDir(path: string): Promise<void>;
rename(from: string, to: string): Promise<void>;
/** Moves to the trash when possible. */
delete(path: string): Promise<void>;
copy(from: string, to: string): Promise<void>;
}
export const tauriFs: ProjectFs = {
readDir: (path) => call(commands.fsReadDir(path)),
createFile: async (path) => {
await call(commands.fsCreateFile(path));
notifyFileOperation({ kind: "created", paths: [path] });
},
createDir: async (path) => void (await call(commands.fsCreateDir(path))),
rename: async (from, to) => void (await call(commands.fsRename(from, to))),
delete: async (path) => {
await call(commands.fsDelete(path));
notifyFileOperation({ kind: "deleted", paths: [path] });
},
copy: async (from, to) => {
await call(commands.fsCopy(from, to));
notifyFileOperation({ kind: "created", paths: [to] });
},
};
type MemNode = { kind: "file"; size: number } | { kind: "dir" };
const err = (kind: "notFound" | "alreadyExists" | "invalidInput", message: string) =>
new IpcError({ kind, message });
/** Minimal in-memory file system with the backend's semantics (no overwrite, folders first). */
export class MemoryFs implements ProjectFs {
readonly nodes = new Map<string, MemNode>();
ignored = new Set<string>();
/** Called after every mutation with the changed paths (simulates the watcher). */
onChange?: (e: { created: string[]; modified: string[]; removed: string[] }) => void;
constructor(files: Record<string, string | null> = {}, root = "/") {
this.nodes.set(root, { kind: "dir" });
for (const [path, content] of Object.entries(files)) {
if (content === null) this.mkdirp(path);
else {
this.mkdirp(parentOf(path));
this.nodes.set(path, { kind: "file", size: content.length });
}
}
}
private mkdirp(path: string) {
for (let p = path; p && !this.nodes.has(p); p = parentOf(p)) {
this.nodes.set(p, { kind: "dir" });
if (p === "/") break;
}
}
private childrenOf(dir: string) {
return [...this.nodes.keys()].filter((p) => p !== dir && parentOf(p) === dir);
}
async readDir(path: string): Promise<FileEntry[]> {
const n = this.nodes.get(path);
if (!n) throw err("notFound", `${path} not found`);
if (n.kind !== "dir") throw err("invalidInput", `${path} is not a directory`);
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
return this.childrenOf(path)
.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: excluded,
excluded,
hidden: name.startsWith("."),
} satisfies FileEntry;
})
.sort((a, b) => Number(b.kind === "dir") - Number(a.kind === "dir") || collator.compare(a.name, b.name));
}
private emit(e: Partial<{ created: string[]; modified: string[]; removed: string[] }>) {
this.onChange?.({ created: e.created ?? [], modified: e.modified ?? [], removed: e.removed ?? [] });
}
async createFile(path: string) {
if (this.nodes.has(path)) throw err("alreadyExists", `${path} already exists`);
this.mkdirp(parentOf(path));
this.nodes.set(path, { kind: "file", size: 0 });
this.emit({ created: [path] });
}
async createDir(path: string) {
if (this.nodes.has(path)) throw err("alreadyExists", `${path} already exists`);
this.mkdirp(path);
this.emit({ created: [path] });
}
async rename(from: string, to: string) {
if (!this.nodes.has(from)) throw err("notFound", `${from} not found`);
if (this.nodes.has(to) && from.toLowerCase() !== to.toLowerCase()) throw err("alreadyExists", `${to} already exists`);
if (isInside(to, from)) throw err("invalidInput", "cannot move a folder into itself");
for (const p of [...this.nodes.keys()]) {
if (p !== from && !isInside(p, from)) continue;
const n = this.nodes.get(p)!;
this.nodes.delete(p);
this.nodes.set(to + p.slice(from.length), n);
}
this.emit({ created: [to], removed: [from] });
}
async delete(path: string) {
if (!this.nodes.has(path)) throw err("notFound", `${path} not found`);
for (const p of [...this.nodes.keys()]) if (p === path || isInside(p, path)) this.nodes.delete(p);
this.emit({ removed: [path] });
}
async copy(from: string, to: string) {
if (!this.nodes.has(from)) throw err("notFound", `${from} not found`);
if (this.nodes.has(to)) throw err("alreadyExists", `${to} already exists`);
for (const p of [...this.nodes.keys()]) {
if (p !== from && !isInside(p, from)) continue;
this.nodes.set(to + p.slice(from.length), { ...this.nodes.get(p)! });
}
this.emit({ created: [to] });
}
}
/** Sample project for plain-browser development (matches the design mock). */
export function sampleFs(root: string): MemoryFs {
const f = (rel: string, content: string | null = "") => [joinPath(root, rel), content] as const;
const fs = new MemoryFs(
Object.fromEntries([
f("src/main/kotlin/com/acme/orders/model/Order.kt"),
f("src/main/kotlin/com/acme/orders/model/OrderStatus.kt"),
f("src/main/kotlin/com/acme/orders/service/OrderService.kt"),
f("src/main/kotlin/com/acme/orders/OrderController.kt"),
f("src/main/kotlin/com/acme/orders/OrderDto.kt"),
f("src/main/kotlin/com/acme/orders/PageRequest.kt"),
f("src/main/kotlin/com/acme/orders/OrderApplication.kt"),
f("src/main/resources/application.yml"),
f("src/main/resources/logo.png"),
f("src/test/kotlin/com/acme/orders/OrderControllerTest.kt"),
f("build", null),
f("build/classes/Main.class"),
f(".git/HEAD"),
f(".gitignore"),
f("build.gradle.kts"),
f("settings.gradle.kts"),
f("README.md"),
...Array.from({ length: 10_000 }, (_, i) => f(`generated/item${i + 1}.json`)),
]),
root,
);
fs.ignored.add(joinPath(root, "build"));
fs.ignored.add(joinPath(root, "generated"));
return fs;
}
let current: ProjectFs = tauriFs;
let sampleRoot: string | null = null;
/** The file system for `root` (the backend, or a sample in plain-browser mode). */
export function projectFs(root: string): ProjectFs {
if (isTauri()) return tauriFs;
if (sampleRoot !== root) {
sampleRoot = root;
const fs = sampleFs(root);
fs.onChange = (e) => queueMicrotask(() => emitLocalIdeEvent({ type: "fsChanged", ...e }));
current = fs;
}
return current;
}
/** Tests: force a file system implementation. */
export function setProjectFsForTests(fs: ProjectFs | null, root: string | null = null) {
current = fs ?? tauriFs;
sampleRoot = fs ? root : null;
}