@polderlabs/openkan 0.4.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,787 @@
1
+ // OpenKan — file/folder path picker modal (M14).
2
+ // window.OpenKanPathPicker = { open(opts) }
3
+ //
4
+ // A small, dependency-free picker that opens over the existing modal layer.
5
+ // Built on top of the same `.modal-backdrop` / `.modal` shell as the rest of
6
+ // the dashboard so it inherits theme variables, keyboard handling, and the
7
+ // backdrop click-to-close behaviour.
8
+ //
9
+ // API contract (server endpoints are owned by kanban/server.ts):
10
+ // GET /api/home → { home: string, entries: FsEntry[] }
11
+ // GET /api/fs?path=X&depth=1 → FsEntry (with .children)
12
+ // GET /api/parents?path=X&maxDepth=8 → FsEntry[]
13
+ //
14
+ // All three are best-effort: a 404 / network failure is shown as an inline
15
+ // error inside the picker (not a thrown promise) so the picker stays usable
16
+ // in environments where the endpoints aren't deployed yet — the user can
17
+ // still type a path and submit it.
18
+
19
+ (() => {
20
+ "use strict";
21
+
22
+ const { api } = window.OpenKanAPI || {};
23
+ if (!api) {
24
+ // Soft fail: surface a console error but don't crash other modules.
25
+ console.error("[path-picker] OpenKanAPI not available — picker is disabled");
26
+ window.OpenKanPathPicker = {
27
+ open() {
28
+ alert("Path picker unavailable: API not loaded.");
29
+ },
30
+ };
31
+ return;
32
+ }
33
+
34
+ // ─── State (per-open) ──────────────────────────────────────────────────
35
+ /** @type {HTMLDivElement|null} */ let root = null;
36
+ /** @type {{
37
+ * title: string, mode: string, onPick: (p:string)=>void, onCancel?: ()=>void,
38
+ * initialPath?: string, showHidden: boolean, currentPath: string,
39
+ * selectedPath: string|null, prevFocus: Element|null
40
+ * }|null} */ let ctx = null;
41
+
42
+ // ─── DOM helpers ──────────────────────────────────────────────────────
43
+ function el(tag, cls, props = {}) {
44
+ const e = document.createElement(tag);
45
+ if (cls) e.className = cls;
46
+ for (const [k, v] of Object.entries(props)) {
47
+ if (k === "text") e.textContent = v;
48
+ else if (k === "html") e.innerHTML = v;
49
+ else if (k === "style" && typeof v === "object") Object.assign(e.style, v);
50
+ else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v);
51
+ else if (v === null || v === undefined) continue;
52
+ else e.setAttribute(k, String(v));
53
+ }
54
+ return e;
55
+ }
56
+
57
+ function setText(node, text) {
58
+ if (node) node.textContent = text == null ? "" : String(text);
59
+ }
60
+
61
+ // ─── Build (lazy) ─────────────────────────────────────────────────────
62
+ function build() {
63
+ if (root) return root;
64
+ root = el("div", "modal-backdrop path-picker-backdrop", {
65
+ id: "path-picker-backdrop",
66
+ role: "dialog",
67
+ "aria-modal": "true",
68
+ "aria-labelledby": "path-picker-title",
69
+ hidden: true,
70
+ });
71
+ root.tabIndex = -1;
72
+
73
+ const modal = el("div", "modal path-picker");
74
+ modal.tabIndex = -1;
75
+
76
+ // Header — title + close
77
+ const header = el("header", "modal-header");
78
+ const titleEl = el("h2", null, {
79
+ id: "path-picker-title",
80
+ text: "Select a folder or file",
81
+ });
82
+ const closeBtn = el("button", "btn-icon", {
83
+ type: "button",
84
+ "aria-label": "Close",
85
+ text: "×",
86
+ });
87
+ closeBtn.dataset.closePathPicker = "1";
88
+ header.append(titleEl, closeBtn);
89
+ modal.append(header);
90
+
91
+ // Breadcrumb / navigation bar
92
+ const navBar = el("div", "path-picker-nav");
93
+ const upBtn = el("button", "btn-icon path-picker-icon-btn", {
94
+ type: "button",
95
+ "aria-label": "Up one level",
96
+ title: "Up one level",
97
+ text: "↑",
98
+ });
99
+ upBtn.dataset.pathPickerUp = "1";
100
+ const homeBtn = el("button", "btn-icon path-picker-icon-btn", {
101
+ type: "button",
102
+ "aria-label": "Home",
103
+ title: "Home",
104
+ text: "⌂",
105
+ });
106
+ homeBtn.dataset.pathPickerHome = "1";
107
+ const crumbs = el("div", "path-picker-breadcrumbs");
108
+ crumbs.dataset.pathPickerCrumbs = "1";
109
+ crumbs.setAttribute("role", "navigation");
110
+ crumbs.setAttribute("aria-label", "Path breadcrumb");
111
+ const refreshBtn = el("button", "btn-icon path-picker-icon-btn", {
112
+ type: "button",
113
+ "aria-label": "Refresh",
114
+ title: "Refresh",
115
+ text: "↻",
116
+ });
117
+ refreshBtn.dataset.pathPickerRefresh = "1";
118
+ navBar.append(upBtn, homeBtn, crumbs, refreshBtn);
119
+ modal.append(navBar);
120
+
121
+ // Layout: tree (left) + form pane (right)
122
+ const layout = el("div", "path-picker-layout");
123
+
124
+ // Tree pane
125
+ const treePane = el("aside", "path-picker-tree-pane");
126
+ const treeHeader = el("div", "path-picker-pane-header");
127
+ treeHeader.append(
128
+ el("span", null, { text: "Contents" }),
129
+ (() => {
130
+ const wrap = el("label", "path-picker-hidden-toggle");
131
+ const cb = el("input", null, { type: "checkbox" });
132
+ cb.dataset.pathPickerHiddenToggle = "1";
133
+ wrap.append(cb, " ", el("span", null, { text: "Show hidden" }));
134
+ return wrap;
135
+ })(),
136
+ );
137
+ treePane.append(treeHeader);
138
+ const tree = el("div", "path-picker-tree");
139
+ tree.dataset.pathPickerTree = "1";
140
+ tree.setAttribute("role", "listbox");
141
+ tree.setAttribute("aria-label", "Files and folders");
142
+ treePane.append(tree);
143
+ layout.append(treePane);
144
+
145
+ // Form pane (right): free-form path input + actions
146
+ const formPane = el("section", "path-picker-form-pane");
147
+ const formHeader = el("div", "path-picker-pane-header");
148
+ formHeader.append(el("span", null, { text: "Path" }));
149
+ formPane.append(formHeader);
150
+
151
+ const pathRow = el("div", "path-picker-input-row");
152
+ const pathInput = el("input", "settings-input", {
153
+ type: "text",
154
+ autocomplete: "off",
155
+ spellcheck: "false",
156
+ placeholder: "/absolute/path/to/folder",
157
+ "aria-label": "Path",
158
+ });
159
+ pathInput.dataset.pathPickerInput = "1";
160
+ const goBtn = el("button", "btn btn-sm", {
161
+ type: "button",
162
+ "aria-label": "Go to path",
163
+ title: "Go to path",
164
+ text: "↗ Go",
165
+ });
166
+ goBtn.dataset.pathPickerGo = "1";
167
+ pathRow.append(pathInput, goBtn);
168
+ formPane.append(pathRow);
169
+
170
+ const selected = el("div", "path-picker-selected");
171
+ selected.dataset.pathPickerSelected = "1";
172
+ selected.append(
173
+ el("span", "path-picker-selected-label", { text: "Selected:" }),
174
+ el("span", "path-picker-selected-value", {
175
+ text: "(none — pick a folder)",
176
+ }),
177
+ );
178
+ formPane.append(selected);
179
+
180
+ const status = el("div", "path-picker-status");
181
+ status.dataset.pathPickerStatus = "1";
182
+ formPane.append(status);
183
+
184
+ const footer = el("footer", "modal-footer path-picker-footer");
185
+ const cancelBtn = el("button", "btn", {
186
+ type: "button",
187
+ "data-close-path-picker": "1",
188
+ text: "Cancel",
189
+ });
190
+ const selectBtn = el("button", "btn btn-primary", {
191
+ type: "button",
192
+ text: "Select",
193
+ });
194
+ selectBtn.dataset.pathPickerSelect = "1";
195
+ footer.append(cancelBtn, selectBtn);
196
+ formPane.append(footer);
197
+
198
+ layout.append(formPane);
199
+ modal.append(layout);
200
+
201
+ root.append(modal);
202
+ document.body.appendChild(root);
203
+
204
+ // ─── Event wiring (one-shot, lives for the life of the page) ───────
205
+ root.addEventListener("click", onRootClick);
206
+
207
+ // Close button + backdrop click + Cancel — use the data attribute set
208
+ // above so we don't have to keep references to the elements.
209
+ document.addEventListener("keydown", onKeydown);
210
+ return root;
211
+ }
212
+
213
+ // ─── Open / close ──────────────────────────────────────────────────────
214
+ /**
215
+ * @param {{
216
+ * title?: string,
217
+ * mode?: "folder"|"file"|"any",
218
+ * initialPath?: string,
219
+ * showHidden?: boolean,
220
+ * onPick: (path:string)=>void,
221
+ * onCancel?: ()=>void,
222
+ * }} opts
223
+ */
224
+ async function open(opts) {
225
+ if (!opts || typeof opts.onPick !== "function") {
226
+ console.error("[path-picker] open() requires { onPick }");
227
+ return;
228
+ }
229
+ const rootEl = build();
230
+ ctx = {
231
+ title: opts.title || "Select a folder or file",
232
+ mode: opts.mode === "file" || opts.mode === "any" ? opts.mode : "folder",
233
+ onPick: opts.onPick,
234
+ onCancel: typeof opts.onCancel === "function" ? opts.onCancel : null,
235
+ initialPath: opts.initialPath || "",
236
+ showHidden: !!opts.showHidden,
237
+ currentPath: "",
238
+ selectedPath: null,
239
+ prevFocus:
240
+ document.activeElement instanceof Element ? document.activeElement : null,
241
+ };
242
+
243
+ // Title.
244
+ const titleEl = rootEl.querySelector("#path-picker-title");
245
+ if (titleEl) titleEl.textContent = ctx.title;
246
+
247
+ // Select-button label follows the mode ("Create" for folder, "Select"
248
+ // for file/any). Falls back to "Select" for unknown modes.
249
+ const selectBtn = rootEl.querySelector('[data-path-picker-select]');
250
+ if (selectBtn) {
251
+ selectBtn.textContent =
252
+ ctx.mode === "folder" ? "Select" : ctx.mode === "file" ? "Select file" : "Select";
253
+ }
254
+
255
+ // Hidden-files toggle.
256
+ const hiddenCb = rootEl.querySelector('[data-path-picker-hidden-toggle]');
257
+ if (hiddenCb) hiddenCb.checked = ctx.showHidden;
258
+
259
+ // Clear stale state, then show the dialog.
260
+ const tree = rootEl.querySelector('[data-path-picker-tree]');
261
+ if (tree) {
262
+ tree.innerHTML = "";
263
+ tree.append(
264
+ el("div", "path-picker-placeholder", { text: "Loading…" }),
265
+ );
266
+ }
267
+ const pathInput = rootEl.querySelector('[data-path-picker-input]');
268
+ if (pathInput) pathInput.value = "";
269
+ const status = rootEl.querySelector('[data-path-picker-status]');
270
+ if (status) status.textContent = "";
271
+
272
+ rootEl.hidden = false;
273
+ document.body.classList.add("path-picker-open");
274
+
275
+ // Decide where to start.
276
+ const seed = (ctx.initialPath || "").trim();
277
+ const target = seed ? seed : null;
278
+ try {
279
+ if (target) {
280
+ await navigateTo(target);
281
+ } else {
282
+ await navigateHome();
283
+ }
284
+ } catch (err) {
285
+ console.error("[path-picker] initial navigate failed:", err);
286
+ setStatus(`Could not load initial directory: ${err?.message || err}`);
287
+ }
288
+ // Focus the path input for keyboard navigation.
289
+ setTimeout(() => {
290
+ try { pathInput?.focus({ preventScroll: true }); } catch {}
291
+ }, 0);
292
+ }
293
+
294
+ function close(reason) {
295
+ if (!root || root.hidden || !ctx) return;
296
+ root.hidden = true;
297
+ document.body.classList.remove("path-picker-open");
298
+ const cancelled = reason !== "pick";
299
+ if (cancelled && typeof ctx.onCancel === "function") {
300
+ try { ctx.onCancel(); } catch (e) {
301
+ console.error("[path-picker] onCancel threw:", e);
302
+ }
303
+ }
304
+ if (ctx.prevFocus && document.contains(ctx.prevFocus)) {
305
+ try { ctx.prevFocus.focus({ preventScroll: true }); } catch {}
306
+ }
307
+ ctx = null;
308
+ }
309
+
310
+ // ─── Navigation ────────────────────────────────────────────────────────
311
+ async function navigateTo(path) {
312
+ if (!ctx) return;
313
+ if (!path || typeof path !== "string") return;
314
+ const abs = normalisePath(path);
315
+ if (!abs) {
316
+ setStatus(`Invalid path: ${path}`);
317
+ return;
318
+ }
319
+ ctx.currentPath = abs;
320
+ ctx.selectedPath = null;
321
+ updateSelectedDisplay();
322
+ setStatus(`Loading ${abs}…`);
323
+ let entry;
324
+ try {
325
+ entry = await fetchFsEntry(abs);
326
+ } catch (err) {
327
+ setStatus(`Failed to load ${abs}: ${err?.message || err}`);
328
+ renderTreeError(abs, err);
329
+ await renderCrumbs(abs);
330
+ return;
331
+ }
332
+ if (!entry || entry.isFile) {
333
+ // If the entry doesn't exist or is a file, we still want breadcrumbs
334
+ // visible — the form input + path display already handles selection.
335
+ setStatus(entry?.isFile ? `${abs} is a file.` : `${abs} is not accessible.`);
336
+ renderTreeEmpty(entry?.isFile ? "Selected path is a file" : "Empty or inaccessible");
337
+ await renderCrumbs(abs);
338
+ return;
339
+ }
340
+ await renderCrumbs(abs);
341
+ setStatus("");
342
+ renderTree(abs, entry.children || []);
343
+ }
344
+
345
+ async function navigateHome() {
346
+ let home = null;
347
+ try {
348
+ const res = await api("GET", "/api/home");
349
+ home = res?.home || null;
350
+ } catch (err) {
351
+ // Endpoint may not exist in this deployment. Fall back to a sensible
352
+ // default so the picker is still usable (Linux/macOS only — Windows
353
+ // doesn't have a /home tree, but neither does the rest of the app).
354
+ home = "/home";
355
+ console.debug(
356
+ "[path-picker] /api/home unavailable, falling back to /home:",
357
+ err?.message || err,
358
+ );
359
+ }
360
+ await navigateTo(home);
361
+ }
362
+
363
+ async function navigateUp() {
364
+ if (!ctx || !ctx.currentPath) return;
365
+ const parent = parentOf(ctx.currentPath);
366
+ if (parent && parent !== ctx.currentPath) {
367
+ await navigateTo(parent);
368
+ }
369
+ }
370
+
371
+ // ─── Data fetching ─────────────────────────────────────────────────────
372
+ async function fetchFsEntry(absPath) {
373
+ const q = `?path=${encodeURIComponent(absPath)}&depth=1`;
374
+ try {
375
+ return await api("GET", `/api/fs${q}`);
376
+ } catch (err) {
377
+ // Re-throw with the path included for better diagnostics.
378
+ throw new Error(`/api/fs failed for ${absPath}: ${err?.message || err}`);
379
+ }
380
+ }
381
+
382
+ async function fetchParents(absPath, maxDepth = 8) {
383
+ const q = `?path=${encodeURIComponent(absPath)}&maxDepth=${maxDepth}`;
384
+ try {
385
+ const res = await api("GET", `/api/parents${q}`);
386
+ return Array.isArray(res) ? res : Array.isArray(res?.parents) ? res.parents : [];
387
+ } catch {
388
+ // Synthesize a breadcrumb from the path string itself — this is a
389
+ // graceful degradation when the endpoint isn't deployed yet.
390
+ return synthesiseParents(absPath);
391
+ }
392
+ }
393
+
394
+ function synthesiseParents(absPath) {
395
+ if (!absPath || absPath === "/") {
396
+ return [{ name: "/", path: "/", isDir: true }];
397
+ }
398
+ const parts = absPath.split("/").filter(Boolean);
399
+ const out = [{ name: "/", path: "/", isDir: true }];
400
+ let acc = "";
401
+ for (const p of parts) {
402
+ acc += "/" + p;
403
+ out.push({ name: p, path: acc, isDir: true });
404
+ }
405
+ return out;
406
+ }
407
+
408
+ // ─── Rendering ────────────────────────────────────────────────────────
409
+ async function renderCrumbs(absPath) {
410
+ const rootEl = build();
411
+ const wrap = rootEl.querySelector('[data-path-picker-crumbs]');
412
+ if (!wrap) return;
413
+ wrap.innerHTML = "";
414
+ const parents = await fetchParents(absPath);
415
+ if (!parents || parents.length === 0) {
416
+ wrap.append(el("span", "path-picker-crumb-leaf", { text: absPath }));
417
+ return;
418
+ }
419
+ parents.forEach((p, idx) => {
420
+ const isLast = idx === parents.length - 1;
421
+ if (idx > 0) {
422
+ wrap.append(
423
+ el("span", "path-picker-crumb-sep", {
424
+ "aria-hidden": "true",
425
+ text: "/",
426
+ }),
427
+ );
428
+ }
429
+ if (isLast) {
430
+ wrap.append(
431
+ el("span", "path-picker-crumb-leaf", {
432
+ text: p.name,
433
+ title: p.path,
434
+ }),
435
+ );
436
+ } else {
437
+ const btn = el("button", "path-picker-crumb", {
438
+ type: "button",
439
+ text: p.name,
440
+ title: p.path,
441
+ });
442
+ btn.addEventListener("click", () => navigateTo(p.path));
443
+ wrap.append(btn);
444
+ }
445
+ });
446
+ }
447
+
448
+ function renderTree(absPath, children) {
449
+ const rootEl = build();
450
+ const tree = rootEl.querySelector('[data-path-picker-tree]');
451
+ if (!tree) return;
452
+ tree.innerHTML = "";
453
+ const filtered = children.filter((c) => ctx?.showHidden || !c.name.startsWith("."));
454
+ if (filtered.length === 0) {
455
+ tree.append(
456
+ el("div", "path-picker-placeholder", { text: "Empty folder" }),
457
+ );
458
+ return;
459
+ }
460
+ // Dirs first, then files; alphabetical within each group.
461
+ const dirs = filtered.filter((c) => c.isDir).sort(byName);
462
+ const files = filtered.filter((c) => !c.isDir).sort(byName);
463
+ for (const child of [...dirs, ...files]) {
464
+ tree.append(renderRow(absPath, child));
465
+ }
466
+ }
467
+
468
+ function renderTreeError(absPath, err) {
469
+ const rootEl = build();
470
+ const tree = rootEl.querySelector('[data-path-picker-tree]');
471
+ if (!tree) return;
472
+ tree.innerHTML = "";
473
+ tree.append(
474
+ el("div", "path-picker-placeholder path-picker-error", {
475
+ text: `Could not list ${absPath}: ${err?.message || err}`,
476
+ }),
477
+ );
478
+ }
479
+
480
+ function renderTreeEmpty(text) {
481
+ const rootEl = build();
482
+ const tree = rootEl.querySelector('[data-path-picker-tree]');
483
+ if (!tree) return;
484
+ tree.innerHTML = "";
485
+ tree.append(el("div", "path-picker-placeholder", { text }));
486
+ }
487
+
488
+ function renderRow(parentPath, child) {
489
+ const isDir = !!child.isDir;
490
+ const row = el("div", "path-picker-row", {
491
+ role: "option",
492
+ "aria-selected": "false",
493
+ "data-path": child.path,
494
+ tabindex: "0",
495
+ });
496
+ if (child.name.startsWith(".")) row.classList.add("path-picker-row-hidden");
497
+ const icon = el("span", "path-picker-icon", {
498
+ "aria-hidden": "true",
499
+ text: isDir ? "📁" : "📄",
500
+ });
501
+ const name = el("span", "path-picker-name", { text: child.name });
502
+ row.append(icon, name);
503
+ if (child.isSymlink) {
504
+ row.append(el("span", "path-picker-symlink", {
505
+ "aria-hidden": "true",
506
+ text: "↪",
507
+ title: "symlink",
508
+ }));
509
+ }
510
+
511
+ const select = () => {
512
+ // Folder: select the folder (mode permits). File: pick immediately if
513
+ // the mode allows files.
514
+ if (!ctx) return;
515
+ if (isDir) {
516
+ ctx.selectedPath = child.path;
517
+ ctx.currentPath = child.path;
518
+ // For folder mode, selecting a folder also navigates into it so the
519
+ // user can drill down. For other modes, selection is sticky until
520
+ // confirmed with the footer button.
521
+ if (ctx.mode === "folder") {
522
+ navigateTo(child.path);
523
+ } else {
524
+ markSelected(row);
525
+ updateSelectedDisplay();
526
+ }
527
+ } else if (ctx.mode !== "folder") {
528
+ // File in file/any mode → pick immediately.
529
+ pick(child.path);
530
+ } else {
531
+ // File in folder-only mode → treat as a no-op (or show a hint).
532
+ setStatus("Folder mode: files cannot be selected.");
533
+ }
534
+ };
535
+
536
+ row.addEventListener("click", select);
537
+ row.addEventListener("dblclick", () => {
538
+ if (isDir) {
539
+ // Double-click jumps into the folder regardless of mode.
540
+ navigateTo(child.path);
541
+ } else {
542
+ pick(child.path);
543
+ }
544
+ });
545
+ row.addEventListener("keydown", (e) => {
546
+ if (e.key === "Enter" || e.key === " ") {
547
+ e.preventDefault();
548
+ select();
549
+ }
550
+ });
551
+ return row;
552
+ }
553
+
554
+ function markSelected(row) {
555
+ if (!row) return;
556
+ const tree = build().querySelector('[data-path-picker-tree]');
557
+ if (!tree) return;
558
+ for (const r of tree.querySelectorAll(".path-picker-row.selected")) {
559
+ r.classList.remove("selected");
560
+ r.setAttribute("aria-selected", "false");
561
+ }
562
+ row.classList.add("selected");
563
+ row.setAttribute("aria-selected", "true");
564
+ try { row.scrollIntoView({ block: "nearest" }); } catch {}
565
+ }
566
+
567
+ function updateSelectedDisplay() {
568
+ const rootEl = build();
569
+ const val = rootEl.querySelector(".path-picker-selected-value");
570
+ if (!val) return;
571
+ if (ctx && ctx.selectedPath) {
572
+ val.textContent = ctx.selectedPath;
573
+ } else if (ctx && ctx.currentPath) {
574
+ val.textContent = `${ctx.currentPath}/`;
575
+ } else {
576
+ val.textContent = "(none — pick a folder)";
577
+ }
578
+ }
579
+
580
+ function setStatus(msg) {
581
+ const rootEl = build();
582
+ const status = rootEl.querySelector('[data-path-picker-status]');
583
+ if (status) status.textContent = msg || "";
584
+ }
585
+
586
+ // ─── Pick ─────────────────────────────────────────────────────────────
587
+ function pick(path) {
588
+ if (!ctx) return;
589
+ const handler = ctx.onPick;
590
+ const value = path;
591
+ close("pick");
592
+ try { handler(value); } catch (e) {
593
+ console.error("[path-picker] onPick threw:", e);
594
+ }
595
+ }
596
+
597
+ function pickCurrent() {
598
+ if (!ctx) return;
599
+ let path = ctx.selectedPath || ctx.currentPath;
600
+ if (!path) {
601
+ setStatus("No path selected yet — navigate or type a path.");
602
+ return;
603
+ }
604
+ // For folder mode, default to the current directory if nothing was
605
+ // explicitly selected (the user is "in" the folder).
606
+ if (!ctx.selectedPath && ctx.currentPath && ctx.mode === "folder") {
607
+ path = ctx.currentPath;
608
+ }
609
+ pick(path);
610
+ }
611
+
612
+ // ─── Path utilities ───────────────────────────────────────────────────
613
+ function normalisePath(p) {
614
+ if (!p) return null;
615
+ let s = String(p).trim();
616
+ if (!s) return null;
617
+ // Collapse trailing slashes (but keep "/" itself).
618
+ if (s.length > 1) s = s.replace(/\/+$/, "");
619
+ return s;
620
+ }
621
+
622
+ function parentOf(p) {
623
+ if (!p || p === "/") return "/";
624
+ const idx = p.lastIndexOf("/");
625
+ if (idx <= 0) return "/";
626
+ return p.slice(0, idx) || "/";
627
+ }
628
+
629
+ function byName(a, b) {
630
+ return String(a.name).localeCompare(String(b.name));
631
+ }
632
+
633
+ // ─── Event delegation ─────────────────────────────────────────────────
634
+ function onRootClick(e) {
635
+ const target = /** @type {HTMLElement|null} */ (e.target);
636
+ if (!target) return;
637
+ if (e.target === root) {
638
+ close("cancel");
639
+ return;
640
+ }
641
+ const closer = target.closest("[data-close-path-picker]");
642
+ if (closer) {
643
+ close("cancel");
644
+ return;
645
+ }
646
+ const upBtn = target.closest("[data-path-picker-up]");
647
+ if (upBtn) {
648
+ navigateUp();
649
+ return;
650
+ }
651
+ const homeBtn = target.closest("[data-path-picker-home]");
652
+ if (homeBtn) {
653
+ navigateHome();
654
+ return;
655
+ }
656
+ const refreshBtn = target.closest("[data-path-picker-refresh]");
657
+ if (refreshBtn) {
658
+ if (ctx?.currentPath) navigateTo(ctx.currentPath);
659
+ return;
660
+ }
661
+ const goBtn = target.closest("[data-path-picker-go]");
662
+ if (goBtn) {
663
+ submitPathInput();
664
+ return;
665
+ }
666
+ const selectBtn = target.closest("[data-path-picker-select]");
667
+ if (selectBtn) {
668
+ pickCurrent();
669
+ return;
670
+ }
671
+ const hiddenCb = target.closest("[data-path-picker-hidden-toggle]");
672
+ if (hiddenCb && hiddenCb instanceof HTMLInputElement) {
673
+ if (ctx) {
674
+ ctx.showHidden = hiddenCb.checked;
675
+ if (ctx.currentPath) navigateTo(ctx.currentPath);
676
+ }
677
+ return;
678
+ }
679
+ }
680
+
681
+ function onKeydown(e) {
682
+ if (!ctx || !root || root.hidden) return;
683
+ if (e.key === "Escape") {
684
+ e.preventDefault();
685
+ e.stopPropagation();
686
+ close("cancel");
687
+ return;
688
+ }
689
+ if (e.key === "Enter") {
690
+ // If the focus is in the path input, submit it. Otherwise Enter picks.
691
+ const ae = document.activeElement;
692
+ if (ae && ae instanceof HTMLInputElement && ae.matches('[data-path-picker-input]')) {
693
+ e.preventDefault();
694
+ submitPathInput();
695
+ return;
696
+ }
697
+ // Don't capture Enter while a row is focused — the row's own handler
698
+ // takes care of selection. Just pick whatever is selected.
699
+ e.preventDefault();
700
+ pickCurrent();
701
+ }
702
+ }
703
+
704
+ function submitPathInput() {
705
+ const rootEl = build();
706
+ const input = rootEl.querySelector('[data-path-picker-input]');
707
+ if (!input || !(input instanceof HTMLInputElement)) return;
708
+ const raw = input.value.trim();
709
+ if (!raw) {
710
+ setStatus("Type a path first.");
711
+ return;
712
+ }
713
+ // Treat a bare "~" or "~/..." as the user's home (very common in shells).
714
+ let target = raw;
715
+ if (raw === "~" || raw.startsWith("~/")) {
716
+ target = "/" + raw.slice(2);
717
+ }
718
+ navigateTo(target);
719
+ }
720
+
721
+ // ─── Command-palette wiring ───────────────────────────────────────────
722
+ function registerPaletteAction() {
723
+ const palette = window.OpenKanCommandPalette;
724
+ if (!palette || typeof palette.registerAction !== "function") return;
725
+ palette.registerAction({
726
+ id: "project.pick-path",
727
+ label: "Open Path Picker",
728
+ hint: "Pick a folder from the filesystem",
729
+ keys: "Cmd/Ctrl+Shift+O",
730
+ run: () => {
731
+ open({
732
+ title: "Choose a folder",
733
+ mode: "folder",
734
+ onPick: (path) => {
735
+ const show =
736
+ window.OpenKanSettings?.showToast ||
737
+ ((m) => console.log("[path-picker]", m));
738
+ try { show(`Picked: ${path}`); } catch {}
739
+ },
740
+ });
741
+ },
742
+ });
743
+ }
744
+
745
+ function bootShortcut() {
746
+ document.addEventListener("keydown", (e) => {
747
+ const isMod = e.metaKey || e.ctrlKey;
748
+ if (isMod && e.shiftKey && (e.key === "o" || e.key === "O")) {
749
+ // Don't fight the OS open shortcut when the user is typing in a
750
+ // non-picker input.
751
+ const ae = document.activeElement;
752
+ if (ae && ae instanceof HTMLInputElement) {
753
+ // Always allow the picker to open even from inputs — it's a
754
+ // global launcher. But skip if the focus is inside the picker
755
+ // itself to avoid recursion.
756
+ if (ae.matches('[data-path-picker-input]')) return;
757
+ }
758
+ e.preventDefault();
759
+ e.stopPropagation();
760
+ open({
761
+ title: "Choose a folder",
762
+ mode: "folder",
763
+ onPick: (path) => {
764
+ const show =
765
+ window.OpenKanSettings?.showToast ||
766
+ ((m) => console.log("[path-picker]", m));
767
+ try { show(`Picked: ${path}`); } catch {}
768
+ },
769
+ });
770
+ }
771
+ });
772
+ }
773
+
774
+ // ─── Boot ─────────────────────────────────────────────────────────────
775
+ window.OpenKanPathPicker = { open, close };
776
+
777
+ function boot() {
778
+ registerPaletteAction();
779
+ bootShortcut();
780
+ }
781
+
782
+ if (document.readyState === "loading") {
783
+ document.addEventListener("DOMContentLoaded", boot, { once: true });
784
+ } else {
785
+ boot();
786
+ }
787
+ })();