finch-file-browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/index.js +804 -0
- package/dist/panel.css +498 -0
- package/dist/panel.html +66 -0
- package/dist/panel.js +18914 -0
- package/i18n/zh-CN.json +9 -0
- package/icon.png +0 -0
- package/package.json +98 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import fs3 from "node:fs";
|
|
4
|
+
import path3 from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/paths.ts
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
11
|
+
".md",
|
|
12
|
+
".markdown",
|
|
13
|
+
".mdx",
|
|
14
|
+
".txt",
|
|
15
|
+
".text",
|
|
16
|
+
".log",
|
|
17
|
+
".csv",
|
|
18
|
+
".tsv",
|
|
19
|
+
".json",
|
|
20
|
+
".jsonl",
|
|
21
|
+
".json5",
|
|
22
|
+
".yaml",
|
|
23
|
+
".yml",
|
|
24
|
+
".toml",
|
|
25
|
+
".ini",
|
|
26
|
+
".cfg",
|
|
27
|
+
".conf",
|
|
28
|
+
".env",
|
|
29
|
+
".js",
|
|
30
|
+
".mjs",
|
|
31
|
+
".cjs",
|
|
32
|
+
".jsx",
|
|
33
|
+
".ts",
|
|
34
|
+
".mts",
|
|
35
|
+
".cts",
|
|
36
|
+
".tsx",
|
|
37
|
+
".vue",
|
|
38
|
+
".svelte",
|
|
39
|
+
".astro",
|
|
40
|
+
".py",
|
|
41
|
+
".rb",
|
|
42
|
+
".go",
|
|
43
|
+
".rs",
|
|
44
|
+
".java",
|
|
45
|
+
".kt",
|
|
46
|
+
".kts",
|
|
47
|
+
".swift",
|
|
48
|
+
".c",
|
|
49
|
+
".h",
|
|
50
|
+
".cc",
|
|
51
|
+
".cpp",
|
|
52
|
+
".hpp",
|
|
53
|
+
".cs",
|
|
54
|
+
".php",
|
|
55
|
+
".lua",
|
|
56
|
+
".pl",
|
|
57
|
+
".r",
|
|
58
|
+
".jl",
|
|
59
|
+
".dart",
|
|
60
|
+
".scala",
|
|
61
|
+
".clj",
|
|
62
|
+
".ex",
|
|
63
|
+
".exs",
|
|
64
|
+
".erl",
|
|
65
|
+
".sh",
|
|
66
|
+
".bash",
|
|
67
|
+
".zsh",
|
|
68
|
+
".fish",
|
|
69
|
+
".ps1",
|
|
70
|
+
".bat",
|
|
71
|
+
".cmd",
|
|
72
|
+
".html",
|
|
73
|
+
".htm",
|
|
74
|
+
".xml",
|
|
75
|
+
".svg",
|
|
76
|
+
".css",
|
|
77
|
+
".scss",
|
|
78
|
+
".sass",
|
|
79
|
+
".less",
|
|
80
|
+
".styl",
|
|
81
|
+
".sql",
|
|
82
|
+
".graphql",
|
|
83
|
+
".gql",
|
|
84
|
+
".proto",
|
|
85
|
+
".tf",
|
|
86
|
+
".hcl",
|
|
87
|
+
".dockerfile",
|
|
88
|
+
".gitignore",
|
|
89
|
+
".npmrc",
|
|
90
|
+
".makefile",
|
|
91
|
+
".mk",
|
|
92
|
+
".cmake",
|
|
93
|
+
".gradle",
|
|
94
|
+
".patch",
|
|
95
|
+
".diff",
|
|
96
|
+
".rst",
|
|
97
|
+
".adoc",
|
|
98
|
+
".tex"
|
|
99
|
+
]);
|
|
100
|
+
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif", ".svg"]);
|
|
101
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
102
|
+
".git",
|
|
103
|
+
".hg",
|
|
104
|
+
".svn",
|
|
105
|
+
"node_modules",
|
|
106
|
+
"__pycache__",
|
|
107
|
+
".venv",
|
|
108
|
+
"venv",
|
|
109
|
+
"env",
|
|
110
|
+
".next",
|
|
111
|
+
".nuxt",
|
|
112
|
+
".turbo",
|
|
113
|
+
".cache",
|
|
114
|
+
".parcel-cache",
|
|
115
|
+
".pytest_cache",
|
|
116
|
+
".mypy_cache",
|
|
117
|
+
".idea",
|
|
118
|
+
".vscode",
|
|
119
|
+
"dist",
|
|
120
|
+
"build",
|
|
121
|
+
"out",
|
|
122
|
+
".output",
|
|
123
|
+
"target",
|
|
124
|
+
"coverage",
|
|
125
|
+
".DS_Store",
|
|
126
|
+
"$RECYCLE.BIN",
|
|
127
|
+
"System Volume Information",
|
|
128
|
+
".Trash"
|
|
129
|
+
]);
|
|
130
|
+
var IGNORED_FILES = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
131
|
+
function extOf(filePath) {
|
|
132
|
+
return path.extname(filePath).toLowerCase();
|
|
133
|
+
}
|
|
134
|
+
function classify(filePath, size, maxTextBytes) {
|
|
135
|
+
const ext = extOf(filePath);
|
|
136
|
+
const base = path.basename(filePath).toLowerCase();
|
|
137
|
+
if (IMAGE_EXTENSIONS.has(ext) && ext !== ".svg") {
|
|
138
|
+
return { kind: "image", editable: false, flavor: "image" };
|
|
139
|
+
}
|
|
140
|
+
if (ext === ".svg") return { kind: "text", editable: true, flavor: "code" };
|
|
141
|
+
const looksText = TEXT_EXTENSIONS.has(ext) || base.startsWith(".") || base === "makefile" || base === "dockerfile" || base === "license" || base === "readme";
|
|
142
|
+
if (!looksText) return { kind: "binary", editable: false, flavor: "binary" };
|
|
143
|
+
if (size > maxTextBytes) return { kind: "too-large", editable: false, flavor: "plain" };
|
|
144
|
+
let flavor = "code";
|
|
145
|
+
if (ext === ".md" || ext === ".markdown" || ext === ".mdx") flavor = "markdown";
|
|
146
|
+
else if ([".txt", ".text", ".log", ".csv", ".tsv", ".rst", ".adoc"].includes(ext)) flavor = "plain";
|
|
147
|
+
return { kind: "text", editable: true, flavor };
|
|
148
|
+
}
|
|
149
|
+
function resolveInside(root, rel) {
|
|
150
|
+
const cleaned = String(rel ?? "").replace(/^[/\\]+/, "");
|
|
151
|
+
const abs = path.resolve(root, cleaned);
|
|
152
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
153
|
+
if (abs !== root && !abs.startsWith(rootWithSep)) return null;
|
|
154
|
+
if (abs.includes("\0")) return null;
|
|
155
|
+
return abs;
|
|
156
|
+
}
|
|
157
|
+
function toRel(root, abs) {
|
|
158
|
+
const rel = path.relative(root, abs);
|
|
159
|
+
return rel.split(path.sep).join("/");
|
|
160
|
+
}
|
|
161
|
+
function readDirEntries(root, relDir) {
|
|
162
|
+
const absDir = resolveInside(root, relDir);
|
|
163
|
+
if (!absDir) return [];
|
|
164
|
+
let names;
|
|
165
|
+
try {
|
|
166
|
+
names = fs.readdirSync(absDir);
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const entries = [];
|
|
171
|
+
for (const name of names) {
|
|
172
|
+
if (IGNORED_FILES.has(name)) continue;
|
|
173
|
+
const abs = path.join(absDir, name);
|
|
174
|
+
let stat;
|
|
175
|
+
try {
|
|
176
|
+
stat = fs.lstatSync(abs);
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const dir = stat.isDirectory();
|
|
181
|
+
if (!dir && !stat.isFile() && !stat.isSymbolicLink()) continue;
|
|
182
|
+
const rel = toRel(root, abs);
|
|
183
|
+
entries.push({
|
|
184
|
+
name,
|
|
185
|
+
rel,
|
|
186
|
+
dir,
|
|
187
|
+
size: dir ? 0 : stat.size,
|
|
188
|
+
mtimeMs: stat.mtimeMs,
|
|
189
|
+
ignored: dir && IGNORED_DIRS.has(name)
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
entries.sort((a, b) => {
|
|
193
|
+
if (a.dir !== b.dir) return a.dir ? -1 : 1;
|
|
194
|
+
return a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true, sensitivity: "base" });
|
|
195
|
+
});
|
|
196
|
+
return entries;
|
|
197
|
+
}
|
|
198
|
+
function findFinchDataRoot(storagePath) {
|
|
199
|
+
const candidates = [];
|
|
200
|
+
const marker = `${path.sep}extension-data${path.sep}`;
|
|
201
|
+
const idx = storagePath.indexOf(marker);
|
|
202
|
+
if (idx > 0) candidates.push(storagePath.slice(0, idx));
|
|
203
|
+
candidates.push(path.join(os.homedir(), ".finch"));
|
|
204
|
+
for (const candidate of candidates) {
|
|
205
|
+
try {
|
|
206
|
+
if (fs.existsSync(path.join(candidate, "pi"))) return candidate;
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/session.ts
|
|
214
|
+
import fs2 from "node:fs";
|
|
215
|
+
import path2 from "node:path";
|
|
216
|
+
var MAX_TRANSCRIPT_BYTES = 24 * 1024 * 1024;
|
|
217
|
+
var PATHISH = /^(?:\.{0,2}\/|~\/|\/)|[\\/]/;
|
|
218
|
+
function unescapeToken(token) {
|
|
219
|
+
return token.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
220
|
+
}
|
|
221
|
+
function findTranscript(dataRoot, sessionId) {
|
|
222
|
+
if (!dataRoot || !sessionId) return null;
|
|
223
|
+
const sessionsDir = path2.join(dataRoot, "pi", "sessions");
|
|
224
|
+
let workspaces;
|
|
225
|
+
try {
|
|
226
|
+
workspaces = fs2.readdirSync(sessionsDir);
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
const suffix = `_${sessionId}.jsonl`;
|
|
231
|
+
let newest = null;
|
|
232
|
+
for (const workspace of workspaces) {
|
|
233
|
+
const dir = path2.join(sessionsDir, workspace);
|
|
234
|
+
let files;
|
|
235
|
+
try {
|
|
236
|
+
files = fs2.readdirSync(dir);
|
|
237
|
+
} catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
if (!file.endsWith(suffix)) continue;
|
|
242
|
+
const full = path2.join(dir, file);
|
|
243
|
+
try {
|
|
244
|
+
const stat = fs2.statSync(full);
|
|
245
|
+
if (!newest || stat.mtimeMs > newest.mtime) newest = { file: full, mtime: stat.mtimeMs };
|
|
246
|
+
} catch {
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return newest?.file ?? null;
|
|
251
|
+
}
|
|
252
|
+
function readSessionMeta(transcriptPath, sessionId) {
|
|
253
|
+
const meta = { sessionId, startedAtMs: 0, cwd: null, transcriptPath };
|
|
254
|
+
if (!transcriptPath) return meta;
|
|
255
|
+
let fd = null;
|
|
256
|
+
try {
|
|
257
|
+
fd = fs2.openSync(transcriptPath, "r");
|
|
258
|
+
const buffer = Buffer.alloc(8192);
|
|
259
|
+
const read = fs2.readSync(fd, buffer, 0, buffer.length, 0);
|
|
260
|
+
const firstLine = buffer.subarray(0, read).toString("utf8").split("\n")[0];
|
|
261
|
+
const parsed = JSON.parse(firstLine);
|
|
262
|
+
if (parsed.type === "session") {
|
|
263
|
+
if (parsed.timestamp) meta.startedAtMs = Date.parse(parsed.timestamp) || 0;
|
|
264
|
+
if (parsed.cwd) meta.cwd = parsed.cwd;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
} finally {
|
|
268
|
+
if (fd !== null) {
|
|
269
|
+
try {
|
|
270
|
+
fs2.closeSync(fd);
|
|
271
|
+
} catch {
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return meta;
|
|
276
|
+
}
|
|
277
|
+
function walkStrings(value, out) {
|
|
278
|
+
if (typeof value === "string") {
|
|
279
|
+
out.push(value);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(value)) {
|
|
283
|
+
for (const item of value) walkStrings(item, out);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (value && typeof value === "object") {
|
|
287
|
+
for (const item of Object.values(value)) walkStrings(item, out);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function tokensFromText(text, out) {
|
|
291
|
+
const slices = text.split(/[\s,;,;、。!?!?()[\]{}<>"'`]+/);
|
|
292
|
+
for (const slice of slices) {
|
|
293
|
+
const token = slice.replace(/[,。;:、!?)》”’]+$/u, "").trim();
|
|
294
|
+
if (!token || token.length > 300) continue;
|
|
295
|
+
if (!PATHISH.test(token)) continue;
|
|
296
|
+
if (!/\.[A-Za-z0-9]{1,8}$/.test(token)) continue;
|
|
297
|
+
out.push(token);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function collectTouchedFiles(root, transcriptPath) {
|
|
301
|
+
if (!transcriptPath) return [];
|
|
302
|
+
let raw;
|
|
303
|
+
try {
|
|
304
|
+
const stat = fs2.statSync(transcriptPath);
|
|
305
|
+
if (stat.size > MAX_TRANSCRIPT_BYTES) return [];
|
|
306
|
+
raw = fs2.readFileSync(transcriptPath, "utf8");
|
|
307
|
+
} catch {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
const ordered = /* @__PURE__ */ new Map();
|
|
311
|
+
const register = (candidate, when, via) => {
|
|
312
|
+
const value = unescapeToken(candidate).trim();
|
|
313
|
+
if (!value || value.length > 400 || value.includes("\n")) return;
|
|
314
|
+
const abs = path2.isAbsolute(value) ? path2.normalize(value) : resolveInside(root, value);
|
|
315
|
+
if (!abs) return;
|
|
316
|
+
const guarded = resolveInside(root, path2.relative(root, abs));
|
|
317
|
+
if (!guarded) return;
|
|
318
|
+
let stat;
|
|
319
|
+
try {
|
|
320
|
+
stat = fs2.statSync(guarded);
|
|
321
|
+
} catch {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!stat.isFile()) return;
|
|
325
|
+
const rel = toRel(root, guarded);
|
|
326
|
+
const existing = ordered.get(rel);
|
|
327
|
+
if (existing) {
|
|
328
|
+
existing.hits += 1;
|
|
329
|
+
existing.lastMs = Math.max(existing.lastMs, when);
|
|
330
|
+
if (via === "tool") existing.via = "tool";
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
ordered.set(rel, { rel, abs: guarded, hits: 1, firstMs: when, lastMs: when, via });
|
|
334
|
+
};
|
|
335
|
+
for (const line of raw.split("\n")) {
|
|
336
|
+
if (!line.trim()) continue;
|
|
337
|
+
let record;
|
|
338
|
+
try {
|
|
339
|
+
record = JSON.parse(line);
|
|
340
|
+
} catch {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (record.type !== "message" || !record.message) continue;
|
|
344
|
+
const when = Date.parse(record.timestamp ?? "") || 0;
|
|
345
|
+
const content = record.message.content;
|
|
346
|
+
if (!Array.isArray(content)) continue;
|
|
347
|
+
for (const block of content) {
|
|
348
|
+
if (!block || typeof block !== "object") continue;
|
|
349
|
+
if (block.type === "toolCall" && block.arguments) {
|
|
350
|
+
const strings = [];
|
|
351
|
+
walkStrings(block.arguments, strings);
|
|
352
|
+
for (const candidate of strings) register(candidate, when, "tool");
|
|
353
|
+
} else if (block.type === "text" && block.text) {
|
|
354
|
+
const tokens = [];
|
|
355
|
+
tokensFromText(String(block.text), tokens);
|
|
356
|
+
for (const candidate of tokens) register(candidate, when, "text");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return [...ordered.values()].sort((a, b) => b.lastMs - a.lastMs);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/index.ts
|
|
364
|
+
var MAX_TEXT_BYTES = 2 * 1024 * 1024;
|
|
365
|
+
var SCAN_MAX_FILES = 8e3;
|
|
366
|
+
var SCAN_MAX_DEPTH = 10;
|
|
367
|
+
var SCAN_TTL_MS = 4e3;
|
|
368
|
+
var TOUCHED_TTL_MS = 3e3;
|
|
369
|
+
var WATCH_DEBOUNCE_MS = 350;
|
|
370
|
+
var TEXT = {
|
|
371
|
+
"zh-CN": {
|
|
372
|
+
noWorkspace: "\u8FD9\u4E2A\u5BF9\u8BDD\u8FD8\u6CA1\u6709\u7ED1\u5B9A\u6587\u4EF6\u5939",
|
|
373
|
+
notFound: "\u6587\u4EF6\u4E0D\u5B58\u5728",
|
|
374
|
+
denied: "\u8DEF\u5F84\u4E0D\u5728\u5F53\u524D\u6587\u4EF6\u5939\u5185",
|
|
375
|
+
tooLarge: "\u6587\u4EF6\u592A\u5927\uFF0C\u5DF2\u4EA4\u7ED9\u7CFB\u7EDF\u7A0B\u5E8F\u6253\u5F00",
|
|
376
|
+
conflict: "\u6587\u4EF6\u5DF2\u88AB\u5176\u4ED6\u7A0B\u5E8F\u4FEE\u6539",
|
|
377
|
+
saved: "\u5DF2\u4FDD\u5B58",
|
|
378
|
+
restored: "\u5DF2\u64A4\u9500\u4E0A\u4E00\u6B21\u4FDD\u5B58",
|
|
379
|
+
noUndo: "\u6CA1\u6709\u53EF\u64A4\u9500\u7684\u4FDD\u5B58\u8BB0\u5F55",
|
|
380
|
+
failed: "\u64CD\u4F5C\u5931\u8D25",
|
|
381
|
+
externalOpened: "\u5DF2\u7528\u7CFB\u7EDF\u7A0B\u5E8F\u6253\u5F00"
|
|
382
|
+
},
|
|
383
|
+
"en-US": {
|
|
384
|
+
noWorkspace: "This conversation has no folder bound to it",
|
|
385
|
+
notFound: "File not found",
|
|
386
|
+
denied: "That path is outside this folder",
|
|
387
|
+
tooLarge: "File is too large \u2014 opened with the system app instead",
|
|
388
|
+
conflict: "The file changed on disk",
|
|
389
|
+
saved: "Saved",
|
|
390
|
+
restored: "Reverted the last save",
|
|
391
|
+
noUndo: "Nothing to undo",
|
|
392
|
+
failed: "Something went wrong",
|
|
393
|
+
externalOpened: "Opened with the system app"
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
function activate(ctx) {
|
|
397
|
+
const storageRoot = ctx.storagePath;
|
|
398
|
+
const dataRoot = findFinchDataRoot(storageRoot);
|
|
399
|
+
const undoDir = path3.join(storageRoot, "undo");
|
|
400
|
+
try {
|
|
401
|
+
fs3.mkdirSync(undoDir, { recursive: true });
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
405
|
+
let appLocale = "en-US";
|
|
406
|
+
void ctx.app.getInfo().then((info) => {
|
|
407
|
+
appLocale = info.locale === "zh-CN" ? "zh-CN" : "en-US";
|
|
408
|
+
}).catch(() => void 0);
|
|
409
|
+
const locale = () => appLocale;
|
|
410
|
+
const t = (key) => TEXT[locale()][key];
|
|
411
|
+
function keyOf(panel) {
|
|
412
|
+
return panel.sessionId ?? `scope:${panel.id}`;
|
|
413
|
+
}
|
|
414
|
+
function resolveRoot(panel, sessionId) {
|
|
415
|
+
const current = ctx.session;
|
|
416
|
+
if (sessionId) {
|
|
417
|
+
const transcriptPath = findTranscript(dataRoot, sessionId);
|
|
418
|
+
const meta = readSessionMeta(transcriptPath, sessionId);
|
|
419
|
+
if (meta.cwd && fs3.existsSync(meta.cwd)) return meta.cwd;
|
|
420
|
+
if (current.id === sessionId && current.cwd) return current.cwd;
|
|
421
|
+
}
|
|
422
|
+
if (current.cwd) return current.cwd;
|
|
423
|
+
return ctx.workspace.directoryPath ?? ctx.workspace.projectPath ?? null;
|
|
424
|
+
}
|
|
425
|
+
function stateFor(panel) {
|
|
426
|
+
const key = keyOf(panel);
|
|
427
|
+
const existing = sessions.get(key);
|
|
428
|
+
if (existing && fs3.existsSync(existing.root)) return existing;
|
|
429
|
+
const sessionId = panel.sessionId ?? ctx.session.id ?? "";
|
|
430
|
+
const root = resolveRoot(panel, panel.sessionId ?? void 0);
|
|
431
|
+
if (!root) return null;
|
|
432
|
+
const transcriptPath = findTranscript(dataRoot, sessionId);
|
|
433
|
+
const meta = readSessionMeta(transcriptPath, sessionId);
|
|
434
|
+
const state = {
|
|
435
|
+
sessionId,
|
|
436
|
+
root,
|
|
437
|
+
startedAtMs: meta.startedAtMs,
|
|
438
|
+
transcriptPath,
|
|
439
|
+
touchedAt: 0,
|
|
440
|
+
touched: [],
|
|
441
|
+
scan: null,
|
|
442
|
+
watcher: null,
|
|
443
|
+
pendingPaths: /* @__PURE__ */ new Set(),
|
|
444
|
+
flushTimer: null,
|
|
445
|
+
panels: /* @__PURE__ */ new Set()
|
|
446
|
+
};
|
|
447
|
+
sessions.set(key, state);
|
|
448
|
+
return state;
|
|
449
|
+
}
|
|
450
|
+
function touchedFor(state) {
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
if (now - state.touchedAt < TOUCHED_TTL_MS) return state.touched;
|
|
453
|
+
state.touchedAt = now;
|
|
454
|
+
try {
|
|
455
|
+
state.touched = collectTouchedFiles(state.root, state.transcriptPath);
|
|
456
|
+
} catch {
|
|
457
|
+
state.touched = [];
|
|
458
|
+
}
|
|
459
|
+
return state.touched;
|
|
460
|
+
}
|
|
461
|
+
function changedSet(state) {
|
|
462
|
+
if (!state.startedAtMs) return /* @__PURE__ */ new Set();
|
|
463
|
+
return new Set(scanOf(state).filter((f) => f.mtimeMs >= state.startedAtMs).map((f) => f.rel));
|
|
464
|
+
}
|
|
465
|
+
function scanOf(state, force = false) {
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
if (!force && state.scan && now - state.scan.at < SCAN_TTL_MS) return state.scan.files;
|
|
468
|
+
const files = [];
|
|
469
|
+
const stack = [{ abs: state.root, depth: 0 }];
|
|
470
|
+
while (stack.length && files.length < SCAN_MAX_FILES) {
|
|
471
|
+
const { abs, depth } = stack.pop();
|
|
472
|
+
if (depth > SCAN_MAX_DEPTH) continue;
|
|
473
|
+
let entries;
|
|
474
|
+
try {
|
|
475
|
+
entries = readDirEntries(state.root, toRel(state.root, abs));
|
|
476
|
+
} catch {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
for (const entry of entries) {
|
|
480
|
+
if (entry.dir) {
|
|
481
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
482
|
+
stack.push({ abs: path3.join(abs, entry.name), depth: depth + 1 });
|
|
483
|
+
} else {
|
|
484
|
+
files.push({ rel: entry.rel, mtimeMs: entry.mtimeMs, size: entry.size });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
state.scan = { at: now, files };
|
|
489
|
+
return files;
|
|
490
|
+
}
|
|
491
|
+
function broadcast(state, message) {
|
|
492
|
+
for (const panel of state.panels) {
|
|
493
|
+
void panel.postMessage(message).catch(() => void 0);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function ensureWatcher(state) {
|
|
497
|
+
if (state.watcher) return;
|
|
498
|
+
try {
|
|
499
|
+
state.watcher = fs3.watch(state.root, { recursive: true }, (_event, filename) => {
|
|
500
|
+
if (!filename) return;
|
|
501
|
+
const rel = String(filename).split(path3.sep).join("/");
|
|
502
|
+
if (rel.split("/").some((part) => IGNORED_DIRS.has(part))) return;
|
|
503
|
+
state.pendingPaths.add(rel);
|
|
504
|
+
if (state.flushTimer) clearTimeout(state.flushTimer);
|
|
505
|
+
state.flushTimer = setTimeout(() => {
|
|
506
|
+
state.flushTimer = null;
|
|
507
|
+
const paths = [...state.pendingPaths];
|
|
508
|
+
state.pendingPaths.clear();
|
|
509
|
+
state.scan = null;
|
|
510
|
+
broadcast(state, { type: "fsChange", paths });
|
|
511
|
+
}, WATCH_DEBOUNCE_MS);
|
|
512
|
+
});
|
|
513
|
+
} catch {
|
|
514
|
+
state.watcher = null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function releasePanel(state, panel) {
|
|
518
|
+
state.panels.delete(panel);
|
|
519
|
+
if (state.panels.size === 0) {
|
|
520
|
+
try {
|
|
521
|
+
state.watcher?.close();
|
|
522
|
+
} catch {
|
|
523
|
+
}
|
|
524
|
+
state.watcher = null;
|
|
525
|
+
if (state.flushTimer) clearTimeout(state.flushTimer);
|
|
526
|
+
state.flushTimer = null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function finchFileUrl(abs) {
|
|
530
|
+
return `finch-file://local?path=${encodeURIComponent(abs)}`;
|
|
531
|
+
}
|
|
532
|
+
function undoSlot(state, rel) {
|
|
533
|
+
const safe = Buffer.from(`${state.sessionId}::${rel}`).toString("base64url").slice(0, 120);
|
|
534
|
+
return path3.join(undoDir, `${safe}.txt`);
|
|
535
|
+
}
|
|
536
|
+
function openFile(state, rel) {
|
|
537
|
+
const abs = resolveInside(state.root, rel);
|
|
538
|
+
if (!abs) return { type: "error", message: t("denied") };
|
|
539
|
+
let stat;
|
|
540
|
+
try {
|
|
541
|
+
stat = fs3.statSync(abs);
|
|
542
|
+
} catch {
|
|
543
|
+
return { type: "error", message: t("notFound") };
|
|
544
|
+
}
|
|
545
|
+
if (!stat.isFile()) return { type: "error", message: t("notFound") };
|
|
546
|
+
const classification = classify(abs, stat.size, MAX_TEXT_BYTES);
|
|
547
|
+
const changed = changedSet(state).has(toRel(state.root, abs));
|
|
548
|
+
const base = {
|
|
549
|
+
type: "file",
|
|
550
|
+
rel: toRel(state.root, abs),
|
|
551
|
+
name: path3.basename(abs),
|
|
552
|
+
absPath: abs,
|
|
553
|
+
size: stat.size,
|
|
554
|
+
mtimeMs: stat.mtimeMs,
|
|
555
|
+
changed,
|
|
556
|
+
hasUndo: fs3.existsSync(undoSlot(state, toRel(state.root, abs)))
|
|
557
|
+
};
|
|
558
|
+
if (classification.kind === "text") {
|
|
559
|
+
let content = "";
|
|
560
|
+
try {
|
|
561
|
+
content = fs3.readFileSync(abs, "utf8");
|
|
562
|
+
} catch {
|
|
563
|
+
return { type: "error", message: t("failed") };
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
...base,
|
|
567
|
+
kind: "text",
|
|
568
|
+
flavor: classification.flavor,
|
|
569
|
+
editable: classification.editable,
|
|
570
|
+
content,
|
|
571
|
+
lineCount: content.split("\n").length
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (classification.kind === "image") {
|
|
575
|
+
return { ...base, kind: "image", flavor: "image", editable: false, url: finchFileUrl(abs) };
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
...base,
|
|
579
|
+
kind: classification.kind,
|
|
580
|
+
flavor: classification.flavor,
|
|
581
|
+
editable: false
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function saveFile(state, rel, content, baseMtimeMs) {
|
|
585
|
+
const abs = resolveInside(state.root, rel);
|
|
586
|
+
if (!abs) return { type: "error", message: t("denied") };
|
|
587
|
+
let stat;
|
|
588
|
+
try {
|
|
589
|
+
stat = fs3.statSync(abs);
|
|
590
|
+
} catch {
|
|
591
|
+
return { type: "error", message: t("notFound") };
|
|
592
|
+
}
|
|
593
|
+
if (baseMtimeMs && Math.abs(stat.mtimeMs - baseMtimeMs) > 1) {
|
|
594
|
+
return { type: "saveConflict", rel, mtimeMs: stat.mtimeMs, size: stat.size };
|
|
595
|
+
}
|
|
596
|
+
const classification = classify(abs, stat.size, MAX_TEXT_BYTES);
|
|
597
|
+
if (!classification.editable) return { type: "error", message: t("denied") };
|
|
598
|
+
try {
|
|
599
|
+
fs3.writeFileSync(undoSlot(state, rel), fs3.readFileSync(abs));
|
|
600
|
+
fs3.writeFileSync(abs, content, "utf8");
|
|
601
|
+
} catch {
|
|
602
|
+
return { type: "error", message: t("failed") };
|
|
603
|
+
}
|
|
604
|
+
state.scan = null;
|
|
605
|
+
const next = fs3.statSync(abs);
|
|
606
|
+
return {
|
|
607
|
+
type: "saved",
|
|
608
|
+
rel,
|
|
609
|
+
reason: "save",
|
|
610
|
+
mtimeMs: next.mtimeMs,
|
|
611
|
+
size: next.size,
|
|
612
|
+
hasUndo: true,
|
|
613
|
+
message: t("saved")
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
function undoFile(state, rel) {
|
|
617
|
+
const slot = undoSlot(state, rel);
|
|
618
|
+
const abs = resolveInside(state.root, rel);
|
|
619
|
+
if (!abs) return { type: "error", message: t("denied") };
|
|
620
|
+
if (!fs3.existsSync(slot)) return { type: "error", message: t("noUndo") };
|
|
621
|
+
try {
|
|
622
|
+
fs3.writeFileSync(abs, fs3.readFileSync(slot));
|
|
623
|
+
fs3.rmSync(slot, { force: true });
|
|
624
|
+
} catch {
|
|
625
|
+
return { type: "error", message: t("failed") };
|
|
626
|
+
}
|
|
627
|
+
state.scan = null;
|
|
628
|
+
const next = fs3.statSync(abs);
|
|
629
|
+
return {
|
|
630
|
+
type: "saved",
|
|
631
|
+
rel,
|
|
632
|
+
reason: "undo",
|
|
633
|
+
mtimeMs: next.mtimeMs,
|
|
634
|
+
size: next.size,
|
|
635
|
+
hasUndo: false,
|
|
636
|
+
message: t("restored")
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function openExternally(state, rel, reveal) {
|
|
640
|
+
const abs = resolveInside(state.root, rel);
|
|
641
|
+
if (!abs) return { type: "error", message: t("denied") };
|
|
642
|
+
if (!fs3.existsSync(abs)) return { type: "error", message: t("notFound") };
|
|
643
|
+
const platform = process.platform;
|
|
644
|
+
const isDir = fs3.statSync(abs).isDirectory();
|
|
645
|
+
try {
|
|
646
|
+
if (platform === "darwin") {
|
|
647
|
+
execFile("open", reveal ? ["-R", abs] : [abs], () => void 0);
|
|
648
|
+
} else if (platform === "win32") {
|
|
649
|
+
if (reveal) execFile("explorer", [`/select,${abs}`], () => void 0);
|
|
650
|
+
else execFile("cmd", ["/c", "start", "", abs], { windowsHide: true }, () => void 0);
|
|
651
|
+
} else {
|
|
652
|
+
execFile("xdg-open", [reveal && !isDir ? path3.dirname(abs) : abs], () => void 0);
|
|
653
|
+
}
|
|
654
|
+
} catch {
|
|
655
|
+
return { type: "error", message: t("failed") };
|
|
656
|
+
}
|
|
657
|
+
return { type: "toast", message: t("externalOpened") };
|
|
658
|
+
}
|
|
659
|
+
function snapshot(state, panel) {
|
|
660
|
+
return {
|
|
661
|
+
type: "snapshot",
|
|
662
|
+
root: state.root,
|
|
663
|
+
rootLabel: path3.basename(state.root) || state.root,
|
|
664
|
+
sessionId: state.sessionId,
|
|
665
|
+
view: panel.view ?? "",
|
|
666
|
+
spaceName: panel.spaceName ?? ctx.workspace.spaceName ?? "",
|
|
667
|
+
locale: locale(),
|
|
668
|
+
canEdit: true,
|
|
669
|
+
sessionStartedAtMs: state.startedAtMs
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function listDir(state, rel) {
|
|
673
|
+
const entries = readDirEntries(state.root, rel);
|
|
674
|
+
const changed = changedSet(state);
|
|
675
|
+
const touched = touchedFor(state);
|
|
676
|
+
const touchMap = new Map(touched.map((item) => [item.rel, item]));
|
|
677
|
+
return {
|
|
678
|
+
type: "dir",
|
|
679
|
+
rel,
|
|
680
|
+
entries: entries.map((entry) => ({
|
|
681
|
+
...entry,
|
|
682
|
+
changed: !entry.dir && changed.has(entry.rel),
|
|
683
|
+
touched: entry.dir ? false : touchMap.has(entry.rel),
|
|
684
|
+
hits: touchMap.get(entry.rel)?.hits ?? 0
|
|
685
|
+
})),
|
|
686
|
+
changed: [...changed],
|
|
687
|
+
touched: touched.map((item) => [item.rel, item.hits])
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function scanFor(state, query, onlyTouched) {
|
|
691
|
+
const needle = query.trim().toLowerCase();
|
|
692
|
+
const changed = changedSet(state);
|
|
693
|
+
const touchMap = new Map(touchedFor(state).map((item) => [item.rel, item.hits]));
|
|
694
|
+
const items = onlyTouched ? touchedFor(state).map((item) => ({ rel: item.rel, mtimeMs: item.lastMs, size: 0, hits: item.hits })) : scanOf(state).map((item) => ({ ...item, hits: touchMap.get(item.rel) ?? 0 }));
|
|
695
|
+
const filtered = needle ? items.filter((item) => item.rel.toLowerCase().includes(needle)) : items;
|
|
696
|
+
return {
|
|
697
|
+
type: "scan",
|
|
698
|
+
query,
|
|
699
|
+
onlyTouched,
|
|
700
|
+
total: filtered.length,
|
|
701
|
+
changed: [...changed],
|
|
702
|
+
files: filtered.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 400).map((item) => ({
|
|
703
|
+
rel: item.rel,
|
|
704
|
+
name: path3.basename(item.rel),
|
|
705
|
+
dir: false,
|
|
706
|
+
size: item.size,
|
|
707
|
+
mtimeMs: item.mtimeMs,
|
|
708
|
+
changed: changed.has(item.rel),
|
|
709
|
+
touched: item.hits > 0,
|
|
710
|
+
hits: item.hits
|
|
711
|
+
}))
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
async function handleMessage(state, panel, raw) {
|
|
715
|
+
const message = raw ?? {};
|
|
716
|
+
const kind = String(message.type ?? "");
|
|
717
|
+
switch (kind) {
|
|
718
|
+
case "ready":
|
|
719
|
+
await panel.postMessage(snapshot(state, panel));
|
|
720
|
+
await panel.postMessage(listDir(state, ""));
|
|
721
|
+
return;
|
|
722
|
+
case "listDir":
|
|
723
|
+
await panel.postMessage(listDir(state, String(message.rel ?? "")));
|
|
724
|
+
return;
|
|
725
|
+
case "open":
|
|
726
|
+
await panel.postMessage(openFile(state, String(message.rel ?? "")));
|
|
727
|
+
return;
|
|
728
|
+
case "save":
|
|
729
|
+
await panel.postMessage(
|
|
730
|
+
saveFile(state, String(message.rel ?? ""), String(message.content ?? ""), Number(message.baseMtimeMs ?? 0))
|
|
731
|
+
);
|
|
732
|
+
return;
|
|
733
|
+
case "undo":
|
|
734
|
+
await panel.postMessage(undoFile(state, String(message.rel ?? "")));
|
|
735
|
+
return;
|
|
736
|
+
case "openExternal":
|
|
737
|
+
await panel.postMessage(openExternally(state, String(message.rel ?? ""), false));
|
|
738
|
+
return;
|
|
739
|
+
case "reveal":
|
|
740
|
+
await panel.postMessage(openExternally(state, String(message.rel ?? ""), true));
|
|
741
|
+
return;
|
|
742
|
+
case "scan":
|
|
743
|
+
await panel.postMessage(scanFor(state, String(message.query ?? ""), Boolean(message.onlyTouched)));
|
|
744
|
+
return;
|
|
745
|
+
case "touched": {
|
|
746
|
+
const files = touchedFor(state).map((item) => ({
|
|
747
|
+
rel: item.rel,
|
|
748
|
+
abs: item.abs,
|
|
749
|
+
hits: item.hits,
|
|
750
|
+
lastMs: item.lastMs,
|
|
751
|
+
via: item.via,
|
|
752
|
+
changed: changedSet(state).has(item.rel)
|
|
753
|
+
}));
|
|
754
|
+
await panel.postMessage({ type: "touched", sessionStartedAtMs: state.startedAtMs, files });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
case "refresh":
|
|
758
|
+
state.scan = null;
|
|
759
|
+
state.touchedAt = 0;
|
|
760
|
+
await panel.postMessage(snapshot(state, panel));
|
|
761
|
+
await panel.postMessage(listDir(state, String(message.rel ?? "")));
|
|
762
|
+
return;
|
|
763
|
+
default:
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
function attach(panel) {
|
|
768
|
+
const state = stateFor(panel);
|
|
769
|
+
if (!state) {
|
|
770
|
+
ctx.subscriptions.push(
|
|
771
|
+
panel.onDidReceiveMessage(async (raw) => {
|
|
772
|
+
const message = raw ?? {};
|
|
773
|
+
if (message.type === "ready") {
|
|
774
|
+
await panel.postMessage({ type: "noWorkspace", message: t("noWorkspace"), locale: locale() });
|
|
775
|
+
}
|
|
776
|
+
})
|
|
777
|
+
);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
state.panels.add(panel);
|
|
781
|
+
ensureWatcher(state);
|
|
782
|
+
const key = keyOf(panel);
|
|
783
|
+
ctx.logger.info(`panel attached: ${panel.id} \u2192 ${state.root}`);
|
|
784
|
+
ctx.subscriptions.push(
|
|
785
|
+
panel.onDidReceiveMessage((raw) => handleMessage(state, panel, raw)),
|
|
786
|
+
panel.onDidDispose(() => {
|
|
787
|
+
releasePanel(state, panel);
|
|
788
|
+
if (state.panels.size === 0 && sessions.get(key) === state) sessions.delete(key);
|
|
789
|
+
})
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
ctx.subscriptions.push(ctx.ui.onDidOpenPanel((panel) => attach(panel)));
|
|
793
|
+
ctx.subscriptions.push(
|
|
794
|
+
ctx.composerActions.register("open-file-browser", {
|
|
795
|
+
async onClick() {
|
|
796
|
+
ctx.ui.createPanel({ instanceMode: "single" });
|
|
797
|
+
}
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
ctx.logger.info(`file browser ready (data root: ${dataRoot ?? "unknown"})`);
|
|
801
|
+
}
|
|
802
|
+
export {
|
|
803
|
+
activate
|
|
804
|
+
};
|