pathnote 0.1.0 → 0.2.1
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/README.md +29 -18
- package/bin/pathnote.js +19 -13
- package/package.json +4 -2
- package/src/documents.js +98 -0
- package/src/history.js +20 -8
- package/src/hub.js +31 -8
- package/src/mcp.js +57 -35
- package/src/workspace.js +244 -0
- package/viewer/index.html +169 -80
- package/src/vault.js +0 -109
package/src/workspace.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The folders Pathnote may touch. Every path from the AI is checked against them: it must be
|
|
2
|
+
// inside one of the folders (after following symlinks), not in a skipped folder, and not a
|
|
3
|
+
// file that usually holds secrets.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { isDocument, isImage, documentText, imageContent, MAX_DOC } from './documents.js';
|
|
8
|
+
|
|
9
|
+
const SKIP = new Set(['.git', '.obsidian', '.trash', '.Trash', 'node_modules', '.build', 'build', 'DerivedData', 'dist', '.wrangler', '.venv', '__pycache__']);
|
|
10
|
+
// Word and LibreOffice leave these lock files next to open documents
|
|
11
|
+
const LOCK = /^(~\$|\.~lock\.)/;
|
|
12
|
+
const SECRET_DIRS = new Set(['secrets', '.ssh', '.gnupg', '.aws']);
|
|
13
|
+
const SECRET_FILE = /^(\.env(\..*)?|.*\.(pem|p8|p12|key|keystore|jks)|id_(rsa|ed25519|ecdsa)(\.pub)?|credentials\.json|token\.json|client_secret.*\.json|\.npmrc|\.netrc)$/i;
|
|
14
|
+
const TEXT = /\.(md|mdx|txt|markdown|csv|tsv|json|jsonc|ya?ml|toml|ini|cfg|conf|xml|html?|css|scss|js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|swift|m|h|c|cc|cpp|hpp|cs|php|sh|zsh|bash|sql|r|lua|tex|bib|srt|vtt|log)$/i;
|
|
15
|
+
const LINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
16
|
+
const MAX_READ = 2 * 1024 * 1024;
|
|
17
|
+
const MAX_SEARCH = 512 * 1024;
|
|
18
|
+
export const MAX_NODES = 6000;
|
|
19
|
+
|
|
20
|
+
const BLOCKED = 'macOS is blocking access to this folder. Allow it in System Settings → Privacy & Security → Files and Folders (for the app that runs Pathnote), then try again.';
|
|
21
|
+
|
|
22
|
+
// macOS holds file calls on protected folders (Desktop, Downloads, iCloud Drive) until the user
|
|
23
|
+
// answers a permission prompt, possibly forever. Give up after `ms` instead of hanging.
|
|
24
|
+
const timeout = (promise, ms) => Promise.race([promise, new Promise((_, no) => setTimeout(() => no(Object.assign(new Error(BLOCKED), { code: 'EBLOCKED' })), ms))]);
|
|
25
|
+
|
|
26
|
+
const exists = p => fs.access(p).then(() => true, () => false);
|
|
27
|
+
|
|
28
|
+
// First file under `dir` that looks like it holds secrets, or null
|
|
29
|
+
async function findSecret(dir) {
|
|
30
|
+
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
31
|
+
const abs = path.join(dir, e.name);
|
|
32
|
+
if (SECRET_FILE.test(e.name) || (e.isDirectory() && SECRET_DIRS.has(e.name))) return abs;
|
|
33
|
+
if (e.isDirectory()) { const found = await findSecret(abs); if (found) return found; }
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const expand = p => String(p || '').trim().replace(/^~(?=$|\/)/, os.homedir());
|
|
39
|
+
|
|
40
|
+
export class Workspace {
|
|
41
|
+
constructor(folders) {
|
|
42
|
+
this.roots = [...new Set(folders.map(f => path.resolve(expand(f))))];
|
|
43
|
+
this.id = this.roots.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Full path for something the AI named. Relative paths are allowed when there is one folder.
|
|
47
|
+
resolve(p) {
|
|
48
|
+
let s = expand(p);
|
|
49
|
+
if (!s) throw new Error('Path is empty.');
|
|
50
|
+
if (!path.isAbsolute(s)) {
|
|
51
|
+
if (this.roots.length > 1) throw new Error(`Use a full path, e.g. ${path.join(this.roots[0], s)}`);
|
|
52
|
+
s = path.join(this.roots[0], s);
|
|
53
|
+
}
|
|
54
|
+
const abs = path.resolve(s);
|
|
55
|
+
const root = this.rootOf(abs);
|
|
56
|
+
if (!root) throw new Error(`"${p}" is outside the connected folders: ${this.roots.join(', ')}`);
|
|
57
|
+
const parts = path.relative(root, abs).split(path.sep);
|
|
58
|
+
if (parts.some(x => SKIP.has(x))) throw new Error(`"${p}" is in a folder Pathnote skips.`);
|
|
59
|
+
if (parts.some(x => SECRET_DIRS.has(x)) || SECRET_FILE.test(path.basename(abs))) throw new Error(`"${p}" looks like it holds secrets, so Pathnote won't open it.`);
|
|
60
|
+
return abs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
rootOf(abs) {
|
|
64
|
+
return this.roots.find(r => abs === r || abs.startsWith(r + path.sep));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// resolve(), then the same check after following symlinks
|
|
68
|
+
async real(p) {
|
|
69
|
+
const abs = this.resolve(p);
|
|
70
|
+
let at = abs;
|
|
71
|
+
for (;;) {
|
|
72
|
+
try { at = await timeout(fs.realpath(at), 5000); break; } catch (e) { if (e.code !== 'ENOENT') throw e; at = path.dirname(at); }
|
|
73
|
+
}
|
|
74
|
+
const roots = await Promise.all(this.roots.map(r => fs.realpath(r).catch(() => r)));
|
|
75
|
+
if (!roots.some(r => at === r || at.startsWith(r + path.sep))) throw new Error(`"${p}" leads outside the connected folders.`);
|
|
76
|
+
return abs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Immediate contents of a folder; folders end with "/"
|
|
80
|
+
async list(dir) {
|
|
81
|
+
const abs = await this.real(dir);
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const e of await timeout(fs.readdir(abs, { withFileTypes: true }), 5000)) {
|
|
84
|
+
if (e.name.startsWith('.') || LOCK.test(e.name) || SKIP.has(e.name) || SECRET_DIRS.has(e.name) || SECRET_FILE.test(e.name)) continue;
|
|
85
|
+
out.push(e.isDirectory() ? e.name + '/' : e.name);
|
|
86
|
+
}
|
|
87
|
+
return out.sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async read(p) {
|
|
91
|
+
const abs = await this.real(p);
|
|
92
|
+
const st = await timeout(fs.stat(abs), 5000);
|
|
93
|
+
if (st.isDirectory()) throw new Error(`"${p}" is a folder. Use list_files.`);
|
|
94
|
+
if (st.size > MAX_READ) throw new Error(`"${p}" is ${(st.size / 1048576).toFixed(1)} MB; Pathnote reads files up to 2 MB.`);
|
|
95
|
+
const buf = await timeout(fs.readFile(abs), 10000);
|
|
96
|
+
if (buf.subarray(0, 8000).includes(0)) throw new Error(`"${p}" is not a text file.`);
|
|
97
|
+
return buf.toString('utf8');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Anything the AI can take in: text as is, documents as their text, pictures as images
|
|
101
|
+
async open(p) {
|
|
102
|
+
const abs = await this.real(p);
|
|
103
|
+
if (!isDocument(abs) && !isImage(abs)) return { kind: 'text', text: await this.read(abs) };
|
|
104
|
+
const st = await timeout(fs.stat(abs), 5000);
|
|
105
|
+
if (st.size > MAX_DOC) throw new Error(`"${p}" is ${(st.size / 1048576).toFixed(1)} MB; Pathnote opens documents and images up to 50 MB.`);
|
|
106
|
+
const buf = await timeout(fs.readFile(abs), 15000);
|
|
107
|
+
if (isImage(abs)) return { kind: 'image', ...(await timeout(imageContent(abs, buf), 20000)) };
|
|
108
|
+
return { kind: 'document', text: await timeout(documentText(abs, buf), 30000) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Document text for search, kept until the file changes
|
|
112
|
+
async docText(abs) {
|
|
113
|
+
const st = await fs.stat(abs);
|
|
114
|
+
if (st.size > MAX_DOC) return null;
|
|
115
|
+
const hit = this.docs?.get(abs);
|
|
116
|
+
if (hit?.mtime === st.mtimeMs) return hit.text;
|
|
117
|
+
const text = await timeout(documentText(abs, await fs.readFile(abs)), 15000);
|
|
118
|
+
(this.docs ??= new Map()).set(abs, { mtime: st.mtimeMs, text });
|
|
119
|
+
return text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read every document's text once in the background, so the first search doesn't wait for it
|
|
123
|
+
async warm() {
|
|
124
|
+
for (const it of await this.walk()) {
|
|
125
|
+
if (it.kind === 'file' && isDocument(it.id)) await this.docText(it.id).catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async readOrNull(p) {
|
|
130
|
+
try { return await this.read(p); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async write(p, text) {
|
|
134
|
+
const abs = await this.real(p);
|
|
135
|
+
if (isDocument(abs) || isImage(abs)) throw new Error(`Pathnote can read ${path.extname(abs)} files but not write them.`);
|
|
136
|
+
await timeout(fs.mkdir(path.dirname(abs), { recursive: true }), 5000);
|
|
137
|
+
await timeout(fs.writeFile(abs, text, 'utf8'), 10000);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Move a file or folder (any type) to a new path in the connected folders. Never overwrites,
|
|
141
|
+
// and refuses folders that hold secret files, since those would leave the protected place.
|
|
142
|
+
async move(from, to) {
|
|
143
|
+
const src = await this.real(from), dst = await this.real(to);
|
|
144
|
+
if (this.roots.includes(src)) throw new Error(`"${from}" is a connected folder itself; move what is inside it instead.`);
|
|
145
|
+
const st = await timeout(fs.stat(src), 5000);
|
|
146
|
+
if (await exists(dst)) throw new Error(`"${to}" already exists; Pathnote never overwrites.`);
|
|
147
|
+
if (dst.startsWith(src + path.sep)) throw new Error(`Can't move "${from}" into itself.`);
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
const secret = await findSecret(src);
|
|
150
|
+
if (secret) throw new Error(`"${from}" holds a file that looks like a secret (${path.relative(src, secret)}), so Pathnote won't move it.`);
|
|
151
|
+
}
|
|
152
|
+
await timeout(fs.mkdir(path.dirname(dst), { recursive: true }), 5000);
|
|
153
|
+
try {
|
|
154
|
+
await fs.rename(src, dst);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e.code !== 'EXDEV') throw e;
|
|
157
|
+
// different disk (e.g. iCloud Drive): copy, then remove the original
|
|
158
|
+
await fs.cp(src, dst, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true });
|
|
159
|
+
await fs.rm(src, { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
return { from: src, to: dst, dir: st.isDirectory() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async remove(p) {
|
|
165
|
+
await fs.rm(await this.real(p), { force: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Every folder and file under the roots, minus skipped, hidden and secret ones. Stops at MAX_NODES.
|
|
169
|
+
async walk() {
|
|
170
|
+
// All folders at once, so several blocked ones cost one timeout, not one each
|
|
171
|
+
const per = await Promise.all(this.roots.map(async r => {
|
|
172
|
+
const mine = [{ id: r, kind: 'root', parent: null }];
|
|
173
|
+
try {
|
|
174
|
+
await timeout(this.visit(r, mine, MAX_NODES), 4000);
|
|
175
|
+
return { items: mine };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (e.code !== 'EBLOCKED') throw e;
|
|
178
|
+
return { items: mine.slice(0, 1), blocked: r };
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
this.blocked = per.filter(x => x.blocked).map(x => x.blocked);
|
|
182
|
+
return per.flatMap(x => x.items).slice(0, MAX_NODES);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async visit(root, items, max) {
|
|
186
|
+
const visit = async (dir, depth) => {
|
|
187
|
+
let entries;
|
|
188
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
189
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
for (const e of entries) {
|
|
191
|
+
if (items.length >= max) return;
|
|
192
|
+
if (e.name.startsWith('.') || LOCK.test(e.name) || SKIP.has(e.name) || SECRET_DIRS.has(e.name) || SECRET_FILE.test(e.name)) continue;
|
|
193
|
+
const abs = path.join(dir, e.name);
|
|
194
|
+
if (e.isDirectory()) { items.push({ id: abs, kind: 'dir', parent: dir }); await visit(abs, depth + 1); }
|
|
195
|
+
else if (e.isFile()) items.push({ id: abs, kind: 'file', parent: dir });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
await visit(root, 1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Text files and documents ranked by how often the query's words appear, file-name matches counted extra
|
|
202
|
+
async search(query, limit = 10) {
|
|
203
|
+
const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
204
|
+
if (!words.length) return [];
|
|
205
|
+
const hits = [];
|
|
206
|
+
for (const it of await this.walk()) {
|
|
207
|
+
if (it.kind !== 'file' || !(TEXT.test(it.id) || isDocument(it.id))) continue;
|
|
208
|
+
let text;
|
|
209
|
+
try {
|
|
210
|
+
if (isDocument(it.id)) text = await this.docText(it.id);
|
|
211
|
+
else {
|
|
212
|
+
const st = await fs.stat(it.id);
|
|
213
|
+
if (st.size > MAX_SEARCH) continue;
|
|
214
|
+
text = await timeout(fs.readFile(it.id, 'utf8'), 3000);
|
|
215
|
+
}
|
|
216
|
+
} catch { continue; }
|
|
217
|
+
if (!text) continue;
|
|
218
|
+
const lower = text.toLowerCase(), name = path.basename(it.id).toLowerCase();
|
|
219
|
+
let score = 0;
|
|
220
|
+
for (const w of words) { score += lower.split(w).length - 1; if (name.includes(w)) score += 5; }
|
|
221
|
+
if (!score) continue;
|
|
222
|
+
const at = Math.max(0, lower.indexOf(words.find(w => lower.includes(w)) ?? words[0]));
|
|
223
|
+
hits.push({ path: it.id, score, snippet: text.slice(Math.max(0, at - 60), at + 140).replace(/\s+/g, ' ').trim() });
|
|
224
|
+
}
|
|
225
|
+
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Folder tree for the viewer, plus [[wikilinks]] between markdown notes (resolved by name, like Obsidian)
|
|
229
|
+
async graph() {
|
|
230
|
+
const items = await this.walk();
|
|
231
|
+
const notes = items.filter(i => i.kind === 'file' && /\.md$/i.test(i.id));
|
|
232
|
+
const byName = new Map(notes.map(n => [path.basename(n.id, path.extname(n.id)).toLowerCase(), n.id]));
|
|
233
|
+
const links = [];
|
|
234
|
+
for (const n of notes) {
|
|
235
|
+
let text;
|
|
236
|
+
try { text = await fs.readFile(n.id, 'utf8'); } catch { continue; }
|
|
237
|
+
for (const m of text.matchAll(LINK)) {
|
|
238
|
+
const target = byName.get(path.basename(m[1].trim()).toLowerCase());
|
|
239
|
+
if (target && target !== n.id) links.push([n.id, target]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { roots: this.roots, items, links, blocked: this.blocked, capped: items.length >= MAX_NODES };
|
|
243
|
+
}
|
|
244
|
+
}
|
package/viewer/index.html
CHANGED
|
@@ -31,6 +31,8 @@ body { background: var(--bg); color: var(--ink); font: 400 14px/1.43 var(--font)
|
|
|
31
31
|
.graph { position: relative; min-width: 0; min-height: 0; border-right: 1px solid var(--line); }
|
|
32
32
|
svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
|
33
33
|
svg.drag { cursor: grabbing; }
|
|
34
|
+
.note { position: absolute; left: 12px; right: 12px; bottom: 12px; padding: 10px 14px; border-radius: 11px; background: var(--surface); color: var(--muted); font-size: 12px; }
|
|
35
|
+
.note b { color: var(--ink); font-weight: 600; }
|
|
34
36
|
.empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; pointer-events: none; }
|
|
35
37
|
.log { display: flex; flex-direction: column; min-height: 0; }
|
|
36
38
|
.log h2 { font-size: 14px; font-weight: 600; margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }
|
|
@@ -50,12 +52,21 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
|
|
|
50
52
|
.btn:disabled { opacity: .4; cursor: default; }
|
|
51
53
|
.hint { padding: 16px; color: var(--muted); }
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
/* --k = user units per screen pixel, so dots, labels and lines keep their on-screen size while zooming */
|
|
56
|
+
svg { --k: 1; }
|
|
57
|
+
line { vector-effect: non-scaling-stroke; }
|
|
58
|
+
.tree { stroke: var(--line); stroke-width: 1; }
|
|
59
|
+
.link { stroke: var(--accent); stroke-width: 1; stroke-dasharray: 3 4; opacity: .35; }
|
|
54
60
|
.trail { stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; transition: stroke-dashoffset .6s ease-in-out, opacity .4s; }
|
|
55
61
|
.src { stroke: var(--accent); stroke-width: 1.5; opacity: .6; }
|
|
56
|
-
.node .dot { fill: var(--node); transition: fill .3s; }
|
|
57
|
-
.node
|
|
58
|
-
.node .
|
|
62
|
+
.node .dot { fill: var(--node); transition: fill .3s; r: calc(var(--k) * 3.5px); }
|
|
63
|
+
.node.dir .dot { r: calc(var(--k) * 5px); }
|
|
64
|
+
.node.root .dot { r: calc(var(--k) * 8px); fill: var(--ink); }
|
|
65
|
+
.node text { font-family: var(--font); font-size: calc(var(--k) * 11px); fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: calc(var(--k) * 4px); stroke-linejoin: round; pointer-events: none; display: none; }
|
|
66
|
+
.node.root text { font-size: calc(var(--k) * 13px); font-weight: 600; fill: var(--ink); }
|
|
67
|
+
.node.root text, .node.top text, .node.hit text, .node.visited text, .node.written text, svg.near .node.dir text, svg.close .node text { display: inline; }
|
|
68
|
+
.node .ring { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; opacity: 0; r: calc(var(--k) * 11px); vector-effect: non-scaling-stroke; }
|
|
69
|
+
.node .pulse { r: calc(var(--k) * 6px); }
|
|
59
70
|
.node.hit .ring { opacity: 1; }
|
|
60
71
|
.node.visited .dot, .node.written .dot { fill: var(--accent); }
|
|
61
72
|
.node.visited text, .node.written text { fill: var(--ink); font-weight: 600; }
|
|
@@ -74,13 +85,14 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
|
|
|
74
85
|
<body>
|
|
75
86
|
<div class="bar">
|
|
76
87
|
<strong>Pathnote</strong>
|
|
77
|
-
<span class="vault" id="
|
|
88
|
+
<span class="vault" id="roots"></span>
|
|
78
89
|
<span class="chip" id="status"><i></i><span id="statusText"></span></span>
|
|
79
90
|
</div>
|
|
80
91
|
<div class="main">
|
|
81
92
|
<div class="graph">
|
|
82
93
|
<svg id="g" role="img"></svg>
|
|
83
94
|
<div class="empty" id="empty" hidden></div>
|
|
95
|
+
<p class="note" id="note" hidden></p>
|
|
84
96
|
</div>
|
|
85
97
|
<div class="log">
|
|
86
98
|
<h2><span id="logTitle"></span><button class="btn" id="clear" type="button"></button></h2>
|
|
@@ -92,12 +104,16 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
|
|
|
92
104
|
const ko = (navigator.language || '').toLowerCase().startsWith('ko');
|
|
93
105
|
const L = ko ? {
|
|
94
106
|
live: '연결됨', off: '연결 끊김', activity: '작업 기록', clear: '경로 지우기', undo: '되돌리기', undone: '되돌림',
|
|
95
|
-
wait: 'AI가
|
|
96
|
-
hits: n => `${n}개 찾음`, created: '새
|
|
107
|
+
wait: 'AI가 파일을 찾거나 읽으면 여기에 기록이 쌓입니다.', empty: '연결된 폴더가 비어 있습니다.',
|
|
108
|
+
hits: n => `${n}개 찾음`, created: '새 파일', graph: '연결된 폴더 구조와 AI가 지나간 경로',
|
|
109
|
+
capped: n => `파일이 많아 ${n}개까지만 그렸습니다.`, folders: n => `폴더 ${n}개`,
|
|
110
|
+
blocked: 'macOS가 막고 있어 비어 보이는 폴더: ', allow: ' 시스템 설정 → 개인정보 보호 및 보안 → 파일 및 폴더에서 Pathnote를 실행하는 앱(Claude 등)에 허용한 뒤 새로고침하세요.'
|
|
97
111
|
} : {
|
|
98
112
|
live: 'Live', off: 'Disconnected', activity: 'Activity', clear: 'Clear path', undo: 'Undo', undone: 'undone',
|
|
99
|
-
wait: 'When the AI
|
|
100
|
-
hits: n => `${n} found`, created: 'new
|
|
113
|
+
wait: 'When the AI looks through or reads your files, it shows up here.', empty: 'The connected folders are empty.',
|
|
114
|
+
hits: n => `${n} found`, created: 'new file', graph: 'Connected folders and the path the AI took',
|
|
115
|
+
capped: n => `Too many files; drew the first ${n}.`, folders: n => `${n} folders`,
|
|
116
|
+
blocked: 'macOS is blocking these folders, so they look empty: ', allow: ' Allow them in System Settings → Privacy & Security → Files and Folders for the app that runs Pathnote (e.g. Claude), then reload.'
|
|
101
117
|
};
|
|
102
118
|
document.documentElement.lang = ko ? 'ko' : 'en';
|
|
103
119
|
document.getElementById('logTitle').textContent = L.activity;
|
|
@@ -107,81 +123,124 @@ document.getElementById('hint').textContent = L.wait;
|
|
|
107
123
|
const NS = 'http://www.w3.org/2000/svg';
|
|
108
124
|
const svg = document.getElementById('g');
|
|
109
125
|
svg.setAttribute('aria-label', L.graph);
|
|
110
|
-
const view = el('g'), gLinks = el('g'), gTrail = el('g'), gNodes = el('g');
|
|
111
|
-
view.append(gLinks, gTrail, gNodes);
|
|
126
|
+
const view = el('g'), gTree = el('g'), gLinks = el('g'), gTrail = el('g'), gNodes = el('g');
|
|
127
|
+
view.append(gTree, gLinks, gTrail, gNodes);
|
|
112
128
|
svg.append(view);
|
|
113
|
-
const pos = new Map(), nodeEl = new Map();
|
|
114
|
-
|
|
129
|
+
const pos = new Map(), nodeEl = new Map(), angleOf = new Map(), depthOf = new Map();
|
|
130
|
+
const RING = 160;
|
|
131
|
+
// connected folders sit on a wide first ring so their names don't crowd the middle
|
|
132
|
+
const radius = depth => (depth + 1.5) * RING;
|
|
133
|
+
let current = null, sources = new Set(), vb = { x: -500, y: -500, w: 1000, h: 1000 };
|
|
115
134
|
|
|
116
135
|
function el(tag, attrs = {}) {
|
|
117
136
|
const e = document.createElementNS(NS, tag);
|
|
118
137
|
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
|
119
138
|
return e;
|
|
120
139
|
}
|
|
121
|
-
const
|
|
140
|
+
const base = p => p.split('/').filter(Boolean).pop() || p;
|
|
141
|
+
const parentOf = p => p.slice(0, p.lastIndexOf('/')) || '/';
|
|
122
142
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
// Radial tree: each folder gets a slice of the circle sized by how many files it holds,
|
|
144
|
+
// and sits one ring further out than its parent
|
|
145
|
+
function layout(items) {
|
|
146
|
+
const kids = new Map([['', []]]);
|
|
147
|
+
for (const it of items) {
|
|
148
|
+
const p = it.kind === 'root' ? '' : it.parent;
|
|
149
|
+
if (!kids.has(p)) kids.set(p, []);
|
|
150
|
+
kids.get(p).push(it.id);
|
|
151
|
+
}
|
|
152
|
+
const leaves = new Map();
|
|
153
|
+
const count = id => {
|
|
154
|
+
const c = kids.get(id) || [];
|
|
155
|
+
const n = c.length ? c.reduce((s, k) => s + count(k), 0) : 1;
|
|
156
|
+
leaves.set(id, n);
|
|
157
|
+
return n;
|
|
158
|
+
};
|
|
159
|
+
count('');
|
|
160
|
+
// a connected folder with few files still gets a readable share of the circle
|
|
161
|
+
const total = leaves.get('');
|
|
162
|
+
for (const r of kids.get('')) leaves.set(r, Math.max(leaves.get(r), total * 0.1));
|
|
163
|
+
leaves.set('', kids.get('').reduce((s, r) => s + leaves.get(r), 0));
|
|
164
|
+
const place = (id, a0, a1, depth) => {
|
|
165
|
+
const a = (a0 + a1) / 2;
|
|
166
|
+
if (id) {
|
|
167
|
+
pos.set(id, [Math.cos(a) * radius(depth), Math.sin(a) * radius(depth)]);
|
|
168
|
+
angleOf.set(id, a); depthOf.set(id, depth);
|
|
143
169
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
let at = a0;
|
|
171
|
+
const sum = (kids.get(id) || []).reduce((s, k) => s + leaves.get(k), 0);
|
|
172
|
+
for (const k of kids.get(id) || []) {
|
|
173
|
+
const span = (a1 - a0) * leaves.get(k) / sum;
|
|
174
|
+
place(k, at, at + span, depth + 1);
|
|
175
|
+
at += span;
|
|
148
176
|
}
|
|
149
|
-
}
|
|
150
|
-
|
|
177
|
+
};
|
|
178
|
+
place('', -Math.PI / 2, Math.PI * 1.5, 0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function setView() {
|
|
182
|
+
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
|
|
183
|
+
const k = vb.w / Math.max(1, svg.clientWidth);
|
|
184
|
+
svg.style.setProperty('--k', k);
|
|
185
|
+
svg.classList.toggle('near', k < 1.2);
|
|
186
|
+
svg.classList.toggle('close', k < 0.5);
|
|
151
187
|
}
|
|
152
188
|
|
|
153
189
|
function fit() {
|
|
154
190
|
const pts = [...pos.values()];
|
|
155
|
-
if (!pts.length) return;
|
|
191
|
+
if (!pts.length) return setView();
|
|
156
192
|
const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]);
|
|
157
|
-
const pad =
|
|
158
|
-
|
|
159
|
-
|
|
193
|
+
const pad = 80, x0 = Math.min(...xs) - pad, y0 = Math.min(...ys) - pad;
|
|
194
|
+
const w = Math.max(...xs) + pad - x0, h = Math.max(...ys) + pad - y0;
|
|
195
|
+
// keep the drawing's aspect so it isn't squashed in a wide panel
|
|
196
|
+
const r = svg.clientWidth / Math.max(1, svg.clientHeight);
|
|
197
|
+
// leave room for file names, which run sideways past the outermost dots
|
|
198
|
+
const label = 220 * Math.max(w, h * r) / Math.max(1, svg.clientWidth);
|
|
199
|
+
const size = Math.max(w + 2 * label, h * r);
|
|
200
|
+
vb = { x: x0 + w / 2 - size / 2, y: y0 + h / 2 - size / r / 2, w: size, h: size / r };
|
|
201
|
+
setView();
|
|
160
202
|
}
|
|
161
203
|
|
|
162
|
-
function addNode(id) {
|
|
204
|
+
function addNode(id, kind, top) {
|
|
163
205
|
const [x, y] = pos.get(id);
|
|
164
|
-
const g = el('g', { class:
|
|
165
|
-
g.append(el('circle', { class: 'pulse', cx: x, cy: y
|
|
166
|
-
|
|
167
|
-
const t = el('text', { x, y:
|
|
168
|
-
t.textContent =
|
|
206
|
+
const g = el('g', { class: `node ${kind}${top ? ' top' : ''}` });
|
|
207
|
+
g.append(el('circle', { class: 'pulse', cx: x, cy: y }), el('circle', { class: 'ring', cx: x, cy: y }), el('circle', { class: 'dot', cx: x, cy: y }));
|
|
208
|
+
const a = angleOf.get(id) ?? 0, right = Math.cos(a) >= 0;
|
|
209
|
+
const t = el('text', { x, y, dx: right ? '0.9em' : '-0.9em', dy: '0.35em', 'text-anchor': right ? 'start' : 'end' });
|
|
210
|
+
t.textContent = base(id);
|
|
169
211
|
const title = el('title'); title.textContent = id;
|
|
170
212
|
g.append(t, title);
|
|
171
213
|
gNodes.append(g);
|
|
172
214
|
nodeEl.set(id, g);
|
|
173
215
|
}
|
|
174
216
|
|
|
217
|
+
function treeLine(a, b) {
|
|
218
|
+
const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
|
|
219
|
+
gTree.append(el('line', { class: 'tree', x1, y1, x2, y2 }));
|
|
220
|
+
}
|
|
221
|
+
|
|
175
222
|
async function loadGraph() {
|
|
176
|
-
const
|
|
177
|
-
document.getElementById('
|
|
178
|
-
document.getElementById('
|
|
179
|
-
|
|
180
|
-
|
|
223
|
+
const g = await (await fetch('/api/graph')).json();
|
|
224
|
+
document.getElementById('roots').textContent = g.roots.map(base).join(' · ');
|
|
225
|
+
document.getElementById('roots').title = g.roots.join('\n');
|
|
226
|
+
const empty = document.getElementById('empty');
|
|
227
|
+
empty.hidden = g.items.some(i => i.kind !== 'root') && !g.capped;
|
|
228
|
+
empty.textContent = g.capped ? L.capped(g.items.length) : L.empty;
|
|
229
|
+
const note = document.getElementById('note');
|
|
230
|
+
note.hidden = !g.blocked?.length;
|
|
231
|
+
if (g.blocked?.length) {
|
|
232
|
+
note.textContent = '';
|
|
233
|
+
const b = document.createElement('b'); b.textContent = L.blocked + g.blocked.map(base).join(', ') + '.';
|
|
234
|
+
note.append(b, L.allow);
|
|
235
|
+
}
|
|
236
|
+
layout(g.items);
|
|
237
|
+
const isRoot = new Set(g.roots);
|
|
238
|
+
for (const it of g.items) if (it.parent && pos.has(it.parent)) treeLine(it.parent, it.id);
|
|
239
|
+
for (const it of g.items) addNode(it.id, it.kind, it.kind === 'dir' && isRoot.has(it.parent));
|
|
240
|
+
for (const [a, b] of g.links) {
|
|
181
241
|
const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
|
|
182
242
|
gLinks.append(el('line', { class: 'link', x1, y1, x2, y2 }));
|
|
183
243
|
}
|
|
184
|
-
nodes.forEach(addNode);
|
|
185
244
|
fit();
|
|
186
245
|
}
|
|
187
246
|
|
|
@@ -202,17 +261,30 @@ function setCurrent(id) {
|
|
|
202
261
|
nodeEl.get(id)?.classList.add('current');
|
|
203
262
|
}
|
|
204
263
|
|
|
205
|
-
// A
|
|
206
|
-
function ensureNode(id) {
|
|
264
|
+
// A file the AI created: put it one ring outside its folder
|
|
265
|
+
function ensureNode(id, kind = 'file') {
|
|
207
266
|
if (pos.has(id)) return;
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
267
|
+
const parent = parentOf(id);
|
|
268
|
+
if (pos.has(parent)) {
|
|
269
|
+
const a = (angleOf.get(parent) ?? 0) + (Math.random() - 0.5) * 0.08, d = (depthOf.get(parent) ?? 1) + 1;
|
|
270
|
+
pos.set(id, [Math.cos(a) * radius(d), Math.sin(a) * radius(d)]); angleOf.set(id, a); depthOf.set(id, d);
|
|
271
|
+
treeLine(parent, id);
|
|
272
|
+
} else {
|
|
273
|
+
pos.set(id, [vb.x + vb.w / 2, vb.y + vb.h / 2]);
|
|
274
|
+
}
|
|
275
|
+
addNode(id, kind);
|
|
213
276
|
document.getElementById('empty').hidden = true;
|
|
214
277
|
}
|
|
215
278
|
|
|
279
|
+
// Move along the path to a node: line from the last place the AI was
|
|
280
|
+
function visit(id, live) {
|
|
281
|
+
if (!nodeEl.has(id)) return;
|
|
282
|
+
if (current && current !== id) line('trail', current, id, live);
|
|
283
|
+
const g = nodeEl.get(id);
|
|
284
|
+
g.classList.remove('hit'); g.classList.add('visited');
|
|
285
|
+
setCurrent(id);
|
|
286
|
+
}
|
|
287
|
+
|
|
216
288
|
const logEl = document.getElementById('log');
|
|
217
289
|
function addLog(e, tool, text, live) {
|
|
218
290
|
document.getElementById('hint').hidden = true;
|
|
@@ -222,7 +294,7 @@ function addLog(e, tool, text, live) {
|
|
|
222
294
|
if (e.type === 'undo') li.classList.add('undo');
|
|
223
295
|
const a = document.createElement('span'); a.className = 'tool'; a.textContent = tool;
|
|
224
296
|
const tm = document.createElement('time'); tm.textContent = new Date(e.t).toLocaleTimeString();
|
|
225
|
-
const b = document.createElement('span'); b.className = 'text'; b.textContent = text;
|
|
297
|
+
const b = document.createElement('span'); b.className = 'text'; b.textContent = text; b.title = e.path || '';
|
|
226
298
|
li.append(a, tm, b);
|
|
227
299
|
if (e.reason) { const w = document.createElement('span'); w.className = 'why'; w.textContent = e.reason; li.append(w); }
|
|
228
300
|
if (e.changeId && e.type !== 'undo') {
|
|
@@ -242,32 +314,48 @@ function addLog(e, tool, text, live) {
|
|
|
242
314
|
logEl.scrollTop = logEl.scrollHeight;
|
|
243
315
|
}
|
|
244
316
|
|
|
317
|
+
// Show paths shortly: the connected folder's name, then the rest
|
|
318
|
+
function short(p) {
|
|
319
|
+
const root = rootsList.find(r => p === r || p.startsWith(r + '/'));
|
|
320
|
+
return root ? base(root) + p.slice(root.length) : p;
|
|
321
|
+
}
|
|
322
|
+
let rootsList = [];
|
|
323
|
+
|
|
245
324
|
function onEvent(e, live) {
|
|
246
325
|
if (e.type === 'search') {
|
|
247
326
|
for (const h of e.hits) nodeEl.get(h)?.classList.add('hit');
|
|
248
|
-
addLog(e, '
|
|
327
|
+
addLog(e, 'search_files', `"${e.query}" · ${L.hits(e.hits.length)}`, live);
|
|
328
|
+
} else if (e.type === 'list') {
|
|
329
|
+
visit(e.path, live);
|
|
330
|
+
addLog(e, 'list_files', short(e.path) + '/', live);
|
|
249
331
|
} else if (e.type === 'read') {
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
g.classList.remove('hit'); g.classList.add('visited');
|
|
254
|
-
sources.add(e.path); setCurrent(e.path);
|
|
255
|
-
}
|
|
256
|
-
addLog(e, 'read_note', e.path, live);
|
|
332
|
+
visit(e.path, live);
|
|
333
|
+
if (nodeEl.has(e.path)) sources.add(e.path);
|
|
334
|
+
addLog(e, 'read_file', short(e.path), live);
|
|
257
335
|
} else if (e.type === 'write' || e.type === 'edit') {
|
|
258
336
|
ensureNode(e.path);
|
|
259
|
-
for (const s of sources) if (s !== e.path) line('src', s, e.path, live);
|
|
337
|
+
for (const s of sources) if (s !== e.path && pos.has(s)) line('src', s, e.path, live);
|
|
260
338
|
const g = nodeEl.get(e.path);
|
|
261
339
|
g.classList.remove('gone'); g.classList.add('written');
|
|
262
340
|
setCurrent(e.path);
|
|
263
|
-
addLog(e, e.type === 'write' ? '
|
|
341
|
+
addLog(e, e.type === 'write' ? 'write_file' : 'edit_file', e.created ? `${short(e.path)} (${L.created})` : short(e.path), live);
|
|
342
|
+
} else if (e.type === 'move') {
|
|
343
|
+
visit(e.from, live);
|
|
344
|
+
ensureNode(e.path, e.dir ? 'dir' : 'file');
|
|
345
|
+
if (pos.has(e.from)) line('src', e.from, e.path, live);
|
|
346
|
+
nodeEl.get(e.from)?.classList.add('gone');
|
|
347
|
+
const g = nodeEl.get(e.path);
|
|
348
|
+
g.classList.remove('gone'); g.classList.add('written');
|
|
349
|
+
setCurrent(e.path);
|
|
350
|
+
addLog(e, 'move_file', `${short(e.from)} → ${short(e.path)}`, live);
|
|
264
351
|
} else if (e.type === 'undo') {
|
|
352
|
+
if (e.back) nodeEl.get(e.back)?.classList.remove('gone');
|
|
265
353
|
if (e.created) {
|
|
266
354
|
nodeEl.get(e.path)?.classList.add('gone');
|
|
267
355
|
gTrail.querySelectorAll(`[data-to="${CSS.escape(e.path)}"]`).forEach(l => l.style.opacity = 0);
|
|
268
356
|
}
|
|
269
357
|
logEl.querySelectorAll(`[data-change="${e.changeId}"]`).forEach(b => { b.disabled = true; b.textContent = L.undone; });
|
|
270
|
-
addLog(e, 'undo', e.path, live);
|
|
358
|
+
addLog(e, 'undo', short(e.path), live);
|
|
271
359
|
}
|
|
272
360
|
}
|
|
273
361
|
|
|
@@ -277,14 +365,14 @@ document.getElementById('clear').onclick = () => {
|
|
|
277
365
|
current = null; sources = new Set();
|
|
278
366
|
};
|
|
279
367
|
|
|
280
|
-
// Drag to pan, wheel to zoom
|
|
368
|
+
// Drag to pan, wheel or pinch to zoom
|
|
281
369
|
let drag = null;
|
|
282
370
|
svg.addEventListener('pointerdown', e => { drag = { x: e.clientX, y: e.clientY, vb: { ...vb } }; svg.classList.add('drag'); svg.setPointerCapture(e.pointerId); });
|
|
283
371
|
svg.addEventListener('pointermove', e => {
|
|
284
372
|
if (!drag) return;
|
|
285
373
|
const k = vb.w / svg.clientWidth;
|
|
286
374
|
vb.x = drag.vb.x - (e.clientX - drag.x) * k; vb.y = drag.vb.y - (e.clientY - drag.y) * k;
|
|
287
|
-
|
|
375
|
+
setView();
|
|
288
376
|
});
|
|
289
377
|
svg.addEventListener('pointerup', () => { drag = null; svg.classList.remove('drag'); });
|
|
290
378
|
svg.addEventListener('wheel', e => {
|
|
@@ -292,14 +380,16 @@ svg.addEventListener('wheel', e => {
|
|
|
292
380
|
const k = Math.exp(e.deltaY * 0.0015), r = svg.getBoundingClientRect();
|
|
293
381
|
const mx = vb.x + (e.clientX - r.left) / r.width * vb.w, my = vb.y + (e.clientY - r.top) / r.height * vb.h;
|
|
294
382
|
vb = { x: mx - (mx - vb.x) * k, y: my - (my - vb.y) * k, w: vb.w * k, h: vb.h * k };
|
|
295
|
-
|
|
383
|
+
setView();
|
|
296
384
|
}, { passive: false });
|
|
385
|
+
addEventListener('resize', fit);
|
|
297
386
|
|
|
298
387
|
(async () => {
|
|
299
388
|
const info = await (await fetch('/api/info')).json();
|
|
300
|
-
|
|
389
|
+
rootsList = info.roots;
|
|
301
390
|
await loadGraph();
|
|
302
391
|
const status = document.getElementById('status'), statusText = document.getElementById('statusText');
|
|
392
|
+
statusText.textContent = L.off;
|
|
303
393
|
let replay = true;
|
|
304
394
|
const es = new EventSource('/api/events');
|
|
305
395
|
es.onopen = () => { status.classList.add('on'); statusText.textContent = L.live; setTimeout(() => { replay = false; }, 300); };
|
|
@@ -311,7 +401,6 @@ svg.addEventListener('wheel', e => {
|
|
|
311
401
|
seen.add(e.key);
|
|
312
402
|
onEvent(e, !replay);
|
|
313
403
|
};
|
|
314
|
-
statusText.textContent = L.off;
|
|
315
404
|
})();
|
|
316
405
|
</script>
|
|
317
406
|
</body>
|