дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
- 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
194 рядки
7.3 KiB
TypeScript
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;
|
|
}
|