openclacky 1.5.11 → 1.5.13

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -1,15 +1,17 @@
1
- // ── Workspace · view — Files tab (download artifacts) ─────────────────────
1
+ // ── Workspace · view - Files tab (tree + inline tabbed viewer) ─────────────
2
2
  //
3
- // Renders the working-directory file tree as the "Files" tab in the session
4
- // aside. The tab is product-positioned as "see what the AI produced and
5
- // download it": clicking a file downloads it; directories expand lazily;
6
- // right-click reveals in the OS file manager (desktop only).
3
+ // Renders the working-directory file tree plus an inline file viewer in the
4
+ // "Files" tab of the session aside. Clicking a file opens it in the viewer
5
+ // right of the tree as a tab: files can be switched / closed, text files are
6
+ // editable with Cmd+S save, images preview inline and markdown has an
7
+ // edit/preview toggle. Right-click on the tree still reveals / copies paths /
8
+ // downloads.
7
9
  //
8
10
  // Registered as a host-owned (built-in) tab via Clacky.ext.ui.mountBuiltin so
9
11
  // it shows for every session regardless of agent profile. All I/O goes through
10
12
  // WorkspaceStore.
11
13
  //
12
- // Depends on: WorkspaceStore, Clacky.ext, I18n, Modal.
14
+ // Depends on: WorkspaceStore, Clacky.ext, I18n, Modal, CodeEditor.
13
15
  // ───────────────────────────────────────────────────────────────────────────
14
16
  "use strict";
15
17
 
@@ -17,8 +19,20 @@ const WorkspaceView = (() => {
17
19
  const t = (key) => (typeof I18n !== "undefined" ? I18n.t(key) : key);
18
20
 
19
21
  const ICON_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
22
+ const ICON_FOLDER_OPEN = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>';
20
23
  const ICON_FILE = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
21
24
  const ICON_CARET = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
25
+ const ICON_COPY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
26
+ const ICON_DOWNLOAD= '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7.5 11l4.5 4.5 4.5-4.5"/><path d="M5 20h14"/></svg>';
27
+ const ICON_SAVE = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>';
28
+ const ICON_EYE = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
29
+ const ICON_CLOSE = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
30
+
31
+ function el(tag, cls) {
32
+ const d = document.createElement(tag);
33
+ if (cls) d.className = cls;
34
+ return d;
35
+ }
22
36
 
23
37
  function formatSize(bytes) {
24
38
  if (bytes == null) return "";
@@ -29,7 +43,191 @@ const WorkspaceView = (() => {
29
43
  return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
30
44
  }
31
45
 
32
- function renderEntries(entries) {
46
+ // ── File-type icons (VS Code Seti-style colored SVGs) ────────────────────
47
+
48
+ const ICON_FONT = 'font-family="ui-sans-serif, system-ui, -apple-system, \'Segoe UI\', sans-serif"';
49
+
50
+ function svgWrap(inner) {
51
+ return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">' + inner + '</svg>';
52
+ }
53
+
54
+ function boxIcon(label, bg, fg, fontSize) {
55
+ const size = fontSize || (label.length >= 2 ? 6.5 : 8);
56
+ return svgWrap(
57
+ `<rect x="1" y="2" width="14" height="12" rx="2.2" fill="${bg}"/>` +
58
+ `<text x="8" y="11.3" text-anchor="middle" ${ICON_FONT} font-size="${size}" font-weight="800" fill="${fg || "#fff"}">${label}</text>`
59
+ );
60
+ }
61
+
62
+ function glyphIcon(glyph, color, fontSize) {
63
+ return svgWrap(
64
+ `<text x="8" y="12.3" text-anchor="middle" ${ICON_FONT} font-size="${fontSize}" font-weight="800" fill="${color}">${glyph}</text>`
65
+ );
66
+ }
67
+
68
+ const ICON_REACT = svgWrap(
69
+ '<g fill="none" stroke="#61dafb" stroke-width="1">' +
70
+ '<ellipse cx="8" cy="8" rx="6.5" ry="2.7"/>' +
71
+ '<ellipse cx="8" cy="8" rx="6.5" ry="2.7" transform="rotate(60 8 8)"/>' +
72
+ '<ellipse cx="8" cy="8" rx="6.5" ry="2.7" transform="rotate(120 8 8)"/>' +
73
+ '<circle cx="8" cy="8" r="1.5" fill="#61dafb" stroke="none"/></g>'
74
+ );
75
+
76
+ const ICON_GIT = svgWrap(
77
+ '<g fill="#e87d3e"><circle cx="5" cy="3.6" r="1.7"/><circle cx="11.5" cy="3.6" r="1.7"/><circle cx="5" cy="12.4" r="1.7"/></g>' +
78
+ '<path d="M5 5.6v5" fill="none" stroke="#e87d3e" stroke-width="1.2"/>' +
79
+ '<path d="M11.5 5.6c0 3-6.5 2-6.5 5" fill="none" stroke="#e87d3e" stroke-width="1.2"/>'
80
+ );
81
+
82
+ const ICON_RUBY = svgWrap('<path fill="#cc342d" d="M3.2 3h9.6l2.7 3.8L8 14.2.5 6.8z"/>');
83
+
84
+ const ICON_LOCK = svgWrap(
85
+ '<rect x="3" y="7" width="10" height="7" rx="1.5" fill="#8a8d94"/>' +
86
+ '<path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7" fill="none" stroke="#8a8d94" stroke-width="1.5"/>'
87
+ );
88
+
89
+ const ICON_ZAP = svgWrap('<path fill="#fbc02d" d="M9.5 1.5L3 9h4l-1.5 5.5L13 7H8.5z"/>');
90
+
91
+ const ICON_WAVE = svgWrap(
92
+ '<g fill="none" stroke="#38bdf8" stroke-width="1.7" stroke-linecap="round">' +
93
+ '<path d="M1.5 6.5q2.5-3 5 0t5 0t3-1.5"/>' +
94
+ '<path d="M1.5 11q2.5-3 5 0t5 0t3-1.5"/></g>'
95
+ );
96
+
97
+ const ICON_DB = svgWrap(
98
+ '<path d="M2.5 4v8.2c0 1.3 2.5 2.3 5.5 2.3s5.5-1 5.5-2.3V4" fill="#e38c00"/>' +
99
+ '<ellipse cx="8" cy="4" rx="5.5" ry="2.3" fill="#f0a830"/>'
100
+ );
101
+
102
+ const ICON_MD = svgWrap(
103
+ '<rect x="1" y="2" width="14" height="12" rx="2.2" fill="#519aba"/>' +
104
+ `<text x="8" y="11.3" text-anchor="middle" ${ICON_FONT} font-size="6" font-weight="800" fill="#fff">M↓</text>`
105
+ );
106
+
107
+ const ICON_NPM = svgWrap(
108
+ '<rect x="1" y="2" width="14" height="12" rx="2.2" fill="#cb3837"/>' +
109
+ `<text x="8" y="11.5" text-anchor="middle" ${ICON_FONT} font-size="8.5" font-weight="800" fill="#fff">n</text>`
110
+ );
111
+
112
+ function imgIcon(color) {
113
+ return svgWrap(
114
+ `<circle cx="5.5" cy="5" r="1.7" fill="${color}" opacity="0.75"/>` +
115
+ `<path d="M2.5 13l3.5-4.5L9 12l2-2.5L13.5 13z" fill="${color}"/>`
116
+ );
117
+ }
118
+
119
+ function tableIcon(color) {
120
+ return svgWrap(
121
+ `<g fill="none" stroke="${color}" stroke-width="1.3">` +
122
+ '<rect x="2" y="3" width="12" height="10" rx="1.2"/>' +
123
+ '<path d="M2 6.7h12M2 10.3h12M7.3 3v10"/></g>'
124
+ );
125
+ }
126
+
127
+ const ICON_DOC = svgWrap(
128
+ '<path fill="#90a4ae" d="M3.5 1.5h6.2L13 4.8v9.2a1 1 0 0 1-1 1H3.5a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z"/>' +
129
+ '<path fill="#c3ccd4" d="M9.7 1.5v3.3H13z"/>'
130
+ );
131
+
132
+ function extOf(name) {
133
+ const n = name.toLowerCase();
134
+ if (n.startsWith(".git")) return "gitfile";
135
+ if (n === ".env" || n.startsWith(".env.")) return "dotenv";
136
+ const i = n.lastIndexOf(".");
137
+ return i > 0 ? n.slice(i + 1) : "";
138
+ }
139
+
140
+ const EXT_ICON_MAP = {
141
+ ts: boxIcon("TS", "#3178c6"), mts: boxIcon("TS", "#3178c6"), cts: boxIcon("TS", "#3178c6"),
142
+ tsx: ICON_REACT, jsx: ICON_REACT,
143
+ js: boxIcon("JS", "#f1e05a", "#20240a"), mjs: boxIcon("JS", "#f1e05a", "#20240a"), cjs: boxIcon("JS", "#f1e05a", "#20240a"),
144
+ json: glyphIcon("{}", "#cbcb41", 9), jsonc: glyphIcon("{}", "#cbcb41", 9),
145
+ rb: ICON_RUBY, rake: ICON_RUBY, erb: ICON_RUBY, gemspec: ICON_RUBY,
146
+ py: boxIcon("PY", "#3572a5"),
147
+ go: boxIcon("GO", "#00add8"),
148
+ rs: boxIcon("RS", "#dea584"),
149
+ java: boxIcon("J", "#b07219"), kt: boxIcon("K", "#a97bff"), kts: boxIcon("K", "#a97bff"),
150
+ swift: boxIcon("SW", "#f05138"),
151
+ c: boxIcon("C", "#283593"), h: boxIcon("H", "#55606a"),
152
+ cpp: boxIcon("C+", "#f34b7d"), hpp: boxIcon("C+", "#f34b7d"), cc: boxIcon("C+", "#f34b7d"), hh: boxIcon("C+", "#f34b7d"),
153
+ css: glyphIcon("#", "#519aba", 12), less: glyphIcon("#", "#519aba", 12),
154
+ scss: glyphIcon("#", "#c6538c", 12), sass: glyphIcon("#", "#c6538c", 12),
155
+ html: glyphIcon("<>", "#e44d26", 8.5), htm: glyphIcon("<>", "#e44d26", 8.5),
156
+ vue: boxIcon("V", "#41b883"), svelte: boxIcon("S", "#ff3e00"),
157
+ md: ICON_MD, mdx: ICON_MD, markdown: ICON_MD,
158
+ yml: glyphIcon("Y", "#a074c4", 12), yaml: glyphIcon("Y", "#a074c4", 12),
159
+ toml: boxIcon("T", "#9c4221"), ini: boxIcon("I", "#9c4221"), conf: boxIcon("C", "#9c4221"), config: boxIcon("C", "#9c4221"),
160
+ sh: glyphIcon("$", "#89e051", 12), bash: glyphIcon("$", "#89e051", 12), zsh: glyphIcon("$", "#89e051", 12), fish: glyphIcon("$", "#89e051", 12),
161
+ svg: imgIcon("#ffb13b"),
162
+ png: imgIcon("#a074c4"), jpg: imgIcon("#a074c4"), jpeg: imgIcon("#a074c4"),
163
+ gif: imgIcon("#a074c4"), webp: imgIcon("#a074c4"), ico: imgIcon("#a074c4"), bmp: imgIcon("#a074c4"),
164
+ pdf: boxIcon("P", "#e04b4b"),
165
+ zip: boxIcon("Z", "#dba15d"), tar: boxIcon("Z", "#dba15d"), gz: boxIcon("Z", "#dba15d"),
166
+ "7z": boxIcon("Z", "#dba15d"), rar: boxIcon("Z", "#dba15d"), dmg: boxIcon("Z", "#dba15d"), iso: boxIcon("Z", "#dba15d"),
167
+ lock: ICON_LOCK,
168
+ sql: ICON_DB,
169
+ csv: tableIcon("#89e051"), tsv: tableIcon("#89e051"),
170
+ ttf: glyphIcon("A", "#d4157f", 12), otf: glyphIcon("A", "#d4157f", 12),
171
+ woff: glyphIcon("A", "#d4157f", 12), woff2: glyphIcon("A", "#d4157f", 12),
172
+ gitfile: ICON_GIT,
173
+ dotenv: boxIcon("E", "#ecd53f", "#3a3000"),
174
+ };
175
+
176
+ function fileIconSvg(name) {
177
+ const n = name.toLowerCase();
178
+ if (n === "package.json" || n === "package-lock.json" || n === "npm-shrinkwrap.json") return ICON_NPM;
179
+ if (n.startsWith("vite.config")) return ICON_ZAP;
180
+ if (n.startsWith("tailwind.config")) return ICON_WAVE;
181
+ if (n.startsWith("postcss.config")) return boxIcon("P", "#dd3a0a");
182
+ if (n.startsWith("eslint.config")) return boxIcon("E", "#4b32c3");
183
+ if (n.startsWith("webpack.config")) return boxIcon("W", "#1c78c0");
184
+ if (n.startsWith("next.config")) return boxIcon("N", "#111827");
185
+ if (n === "dockerfile" || n.startsWith("dockerfile.")) return boxIcon("D", "#0db7ed");
186
+ if (n === "gemfile" || n === "rustfile") return ICON_RUBY;
187
+ if (n === "makefile" || n.startsWith("makefile.")) return boxIcon("M", "#6d8086");
188
+ if (n.startsWith("license") || n.startsWith("licence") || n.startsWith("copying")) return ICON_DOC;
189
+ return EXT_ICON_MAP[extOf(name)] || ICON_DOC;
190
+ }
191
+
192
+
193
+ // ── File tree ───────────────────────────────────────────────────────────
194
+
195
+ const treeState = { activePath: null, treeEl: null, indexPromise: null };
196
+
197
+ function highlightActiveRow() {
198
+ const tree = treeState.treeEl;
199
+ if (!tree) return;
200
+ tree.querySelectorAll(".wt-row.active").forEach((r) => r.classList.remove("active"));
201
+ if (!treeState.activePath) return;
202
+ const row = tree.querySelector(`.wt-row[data-path="${CSS.escape(treeState.activePath)}"]`);
203
+ if (row) {
204
+ row.classList.add("active");
205
+ row.scrollIntoView({ block: "nearest" });
206
+ }
207
+ }
208
+
209
+ function setActiveFilePath(path) {
210
+ treeState.activePath = path;
211
+ highlightActiveRow();
212
+ }
213
+
214
+ let lastGuidedRow = null;
215
+
216
+ function updateIndentGuides(row) {
217
+ const tree = treeState.treeEl;
218
+ if (!tree) return;
219
+ if (row === lastGuidedRow) return;
220
+ lastGuidedRow = row;
221
+ tree.querySelectorAll(".wt-children.guide-on").forEach((c) => c.classList.remove("guide-on"));
222
+ if (!row) return;
223
+ let p = row.parentElement;
224
+ while (p && p !== tree) {
225
+ if (p.classList.contains("wt-children")) p.classList.add("guide-on");
226
+ p = p.parentElement;
227
+ }
228
+ }
229
+
230
+ function renderEntries(entries, openFile) {
33
231
  const frag = document.createDocumentFragment();
34
232
  if (!entries.length) {
35
233
  const empty = document.createElement("div");
@@ -38,17 +236,19 @@ const WorkspaceView = (() => {
38
236
  frag.appendChild(empty);
39
237
  return frag;
40
238
  }
41
- for (const entry of entries) frag.appendChild(buildNode(entry));
239
+ for (const entry of entries) frag.appendChild(buildNode(entry, openFile));
42
240
  return frag;
43
241
  }
44
242
 
45
- function buildNode(entry) {
243
+ function buildNode(entry, openFile) {
46
244
  const node = document.createElement("div");
47
245
  node.className = "wt-node";
246
+ node.dataset.type = entry.type;
48
247
 
49
248
  const row = document.createElement("div");
50
249
  row.className = "wt-row";
51
250
  row.title = entry.name;
251
+ row.dataset.path = entry.path;
52
252
 
53
253
  const caret = document.createElement("span");
54
254
  caret.className = "wt-caret" + (entry.type === "dir" ? "" : " leaf");
@@ -56,7 +256,11 @@ const WorkspaceView = (() => {
56
256
 
57
257
  const icon = document.createElement("span");
58
258
  icon.className = "wt-icon";
59
- icon.innerHTML = entry.type === "dir" ? ICON_FOLDER : ICON_FILE;
259
+ if (entry.type === "dir") {
260
+ icon.innerHTML = ICON_FOLDER;
261
+ } else {
262
+ icon.innerHTML = fileIconSvg(entry.name);
263
+ }
60
264
 
61
265
  const name = document.createElement("span");
62
266
  name.className = "wt-name";
@@ -80,7 +284,7 @@ const WorkspaceView = (() => {
80
284
  children.className = "wt-children";
81
285
  children.style.display = "none";
82
286
  node.appendChild(children);
83
- row.addEventListener("click", () => toggleDir(entry, caret, children));
287
+ row.addEventListener("click", () => toggleDir(entry, caret, children, icon, openFile));
84
288
  } else {
85
289
  row.addEventListener("click", () => openFile(entry));
86
290
  }
@@ -96,14 +300,11 @@ const WorkspaceView = (() => {
96
300
  function showContextMenu(e, entry) {
97
301
  closeContextMenu();
98
302
 
99
- const iconFolder = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
100
- const iconDownload = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7.5 11l4.5 4.5 4.5-4.5"/><path d="M5 20h14"/></svg>';
101
- const iconCopy = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
102
303
  const iconRelPath = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/><path d="M12 19l3-3"/></svg>';
103
304
 
104
305
  const downloadItem = entry.type === "file" ? `
105
306
  <div class="session-actions-menu-item" data-action="download">
106
- <span class="session-actions-menu-icon">${iconDownload}</span>
307
+ <span class="session-actions-menu-icon">${ICON_DOWNLOAD}</span>
107
308
  <span class="session-actions-menu-label">${t("workspace.download")}</span>
108
309
  </div>` : "";
109
310
 
@@ -111,11 +312,11 @@ const WorkspaceView = (() => {
111
312
  menu.className = "wt-context-menu session-context-menu";
112
313
  menu.innerHTML = `
113
314
  <div class="session-actions-menu-item" data-action="reveal">
114
- <span class="session-actions-menu-icon">${iconFolder}</span>
315
+ <span class="session-actions-menu-icon">${ICON_FOLDER}</span>
115
316
  <span class="session-actions-menu-label">${t("workspace.revealInFinder")}</span>
116
317
  </div>
117
318
  <div class="session-actions-menu-item" data-action="copypath">
118
- <span class="session-actions-menu-icon">${iconCopy}</span>
319
+ <span class="session-actions-menu-icon">${ICON_COPY}</span>
119
320
  <span class="session-actions-menu-label">${t("workspace.copyPath")}</span>
120
321
  </div>
121
322
  <div class="session-actions-menu-item" data-action="copyrelpath">
@@ -184,62 +385,32 @@ const WorkspaceView = (() => {
184
385
  }
185
386
  }
186
387
 
187
- async function toggleDir(entry, caret, children) {
388
+ async function toggleDir(entry, caret, children, iconEl, openFile) {
188
389
  const isOpen = caret.classList.contains("open");
189
390
  if (isOpen) {
190
391
  caret.classList.remove("open");
191
392
  children.style.display = "none";
393
+ if (iconEl) iconEl.innerHTML = ICON_FOLDER;
192
394
  return;
193
395
  }
194
396
  caret.classList.add("open");
195
397
  children.style.display = "";
398
+ if (iconEl) iconEl.innerHTML = ICON_FOLDER_OPEN;
196
399
  if (children.dataset.loaded === "1") return;
197
400
 
198
401
  children.innerHTML = `<div class="wt-loading">${t("workspace.loading")}</div>`;
199
402
  try {
200
403
  const entries = await Workspace.fetchEntries(entry.path);
201
404
  children.innerHTML = "";
202
- children.appendChild(renderEntries(entries));
405
+ children.appendChild(renderEntries(entries, openFile));
203
406
  children.dataset.loaded = "1";
407
+ highlightActiveRow();
204
408
  } catch (err) {
205
409
  console.error("workspace load failed:", err);
206
410
  children.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
207
411
  }
208
412
  }
209
413
 
210
- async function openFile(entry) {
211
- const kind = CodeEditor.fileKind(entry.name);
212
- if (kind === "binary") {
213
- Modal.toast(t("workspace.previewUnsupported"), "info");
214
- return;
215
- }
216
- if (kind === "image") {
217
- try {
218
- const blob = await Workspace.fetchFileBlob(entry);
219
- const url = URL.createObjectURL(blob);
220
- CodeEditor.open({ filename: entry.name, title: entry.name, kind: "image", imageUrl: url, onClose: () => URL.revokeObjectURL(url) });
221
- } catch (err) {
222
- console.error("preview failed:", err);
223
- Modal.toast(t("workspace.previewFailed"), "error");
224
- }
225
- return;
226
- }
227
- try {
228
- const text = await Workspace.fetchFileText(entry);
229
- CodeEditor.open({
230
- filename: entry.name,
231
- title: entry.name,
232
- content: text,
233
- onSave: async (content) => {
234
- await Workspace.saveFileText(entry, content);
235
- }
236
- });
237
- } catch (err) {
238
- console.error("preview failed:", err);
239
- Modal.toast(t("workspace.previewFailed"), "error");
240
- }
241
- }
242
-
243
414
  async function downloadFile(entry) {
244
415
  try {
245
416
  const blob = await Workspace.fetchFileBlob(entry);
@@ -257,56 +428,571 @@ const WorkspaceView = (() => {
257
428
  }
258
429
  }
259
430
 
260
- async function loadRoot(tree) {
431
+ async function loadRoot(tree, openFile) {
261
432
  if (!tree || !Workspace.state.hasSession()) return;
262
433
  tree.innerHTML = `<div class="wt-loading">${t("workspace.loading")}</div>`;
263
434
  try {
264
435
  const entries = await Workspace.fetchEntries("");
265
436
  tree.innerHTML = "";
266
- tree.appendChild(renderEntries(entries));
437
+ tree.appendChild(renderEntries(entries, openFile));
267
438
  } catch (err) {
268
439
  console.error("workspace load failed:", err);
269
440
  tree.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
270
441
  }
271
442
  }
272
443
 
273
- // Build the Files tab body for the current session.
274
- function renderFilesTab(_ctx) {
275
- const wrap = document.createElement("div");
276
- wrap.className = "wt-panel";
277
-
278
- const bar = document.createElement("div");
279
- bar.className = "wt-bar";
280
- const hint = document.createElement("span");
281
- hint.className = "wt-bar-hint";
282
- hint.textContent = t("workspace.contextMenuHint");
283
- const refresh = document.createElement("button");
444
+ // ── Inline tabbed viewer ────────────────────────────────────────────────
445
+
446
+ // The tree + viewer split needs horizontal room; widen the aside once when
447
+ // the first file is opened (still user-resizable afterwards).
448
+ function ensureAsideWidth() {
449
+ const aside = document.getElementById("session-aside");
450
+ if (!aside) return;
451
+ let w = 0;
452
+ try {
453
+ w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width")) || 0;
454
+ } catch (_e) { w = 0; }
455
+ const target = 620;
456
+ if (w < target) {
457
+ aside.style.setProperty("--session-aside-width", target + "px");
458
+ try { localStorage.setItem("clacky.aside.width", String(target)); } catch (_e) { /* non-fatal */ }
459
+ }
460
+ }
461
+
462
+ function createViewer(onTabsChanged) {
463
+ const tabs = [];
464
+ let activeId = null;
465
+ const notify = () => { if (onTabsChanged) onTabsChanged({ count: tabs.length, activeId }); };
466
+
467
+ const root = el("div", "wv-viewer");
468
+
469
+ const tabbar = el("div", "wv-tabbar");
470
+ const tabsEl = el("div", "wv-tabs");
471
+ tabsEl.setAttribute("role", "tablist");
472
+ const actionsEl = el("div", "wv-actions");
473
+
474
+ const content = el("div", "wv-content");
475
+
476
+ function actionBtn(icon, title, cls) {
477
+ const btn = el("button", "wv-action" + (cls ? " " + cls : ""));
478
+ btn.type = "button";
479
+ btn.title = title;
480
+ btn.innerHTML = icon;
481
+ return btn;
482
+ }
483
+
484
+ const previewBtn = actionBtn(ICON_EYE, t("workspace.preview"));
485
+ const copyBtn = actionBtn(ICON_COPY, t("workspace.copyContent"));
486
+ const downloadBtn = actionBtn(ICON_DOWNLOAD, t("workspace.download"));
487
+ const saveBtn = actionBtn(ICON_SAVE, t("modal.save"));
488
+ actionsEl.appendChild(previewBtn);
489
+ actionsEl.appendChild(copyBtn);
490
+ actionsEl.appendChild(downloadBtn);
491
+ actionsEl.appendChild(saveBtn);
492
+
493
+ tabbar.appendChild(tabsEl);
494
+ tabbar.appendChild(actionsEl);
495
+
496
+ root.appendChild(tabbar);
497
+ root.appendChild(content);
498
+
499
+ const activeTab = () => tabs.find((x) => x.id === activeId) || null;
500
+
501
+ function rebuildTabbar() {
502
+ tabsEl.replaceChildren();
503
+ tabs.forEach((tab) => {
504
+ const btn = el("button", "wv-tab" + (tab.id === activeId ? " active" : ""));
505
+ btn.type = "button";
506
+ btn.title = tab.entry.path;
507
+ btn.setAttribute("role", "tab");
508
+
509
+ const label = el("span", "wv-tab-label");
510
+ label.textContent = tab.entry.name;
511
+
512
+ const dot = el("span", "wv-tab-dot");
513
+ dot.style.display = tab.dirty ? "" : "none";
514
+
515
+ const close = el("span", "wv-tab-close");
516
+ close.innerHTML = ICON_CLOSE;
517
+ close.title = t("workspace.closeTab");
518
+
519
+ btn.appendChild(label);
520
+ btn.appendChild(dot);
521
+ btn.appendChild(close);
522
+
523
+ btn.addEventListener("click", (e) => {
524
+ if (e.target.closest(".wv-tab-close")) return;
525
+ activate(tab.id);
526
+ });
527
+ close.addEventListener("click", (e) => {
528
+ e.stopPropagation();
529
+ closeTab(tab.id);
530
+ });
531
+ btn.addEventListener("auxclick", (e) => {
532
+ if (e.button === 1) closeTab(tab.id);
533
+ });
534
+
535
+ tabsEl.appendChild(btn);
536
+ });
537
+ }
538
+
539
+ function updateActions() {
540
+ const tab = activeTab();
541
+ previewBtn.style.display = tab && tab.kind === "text" && tab.isMd ? "" : "none";
542
+ copyBtn.style.display = tab && tab.kind === "text" ? "" : "none";
543
+ saveBtn.style.display = tab && tab.kind === "text" ? "" : "none";
544
+ downloadBtn.style.display = tab ? "" : "none";
545
+ previewBtn.classList.toggle("active", !!(tab && tab.previewMode));
546
+ saveBtn.classList.toggle("dirty", !!(tab && tab.dirty));
547
+ }
548
+
549
+ function activate(id) {
550
+ activeId = id;
551
+ tabs.forEach((tab) => tab.el.classList.toggle("active", tab.id === id));
552
+ rebuildTabbar();
553
+ updateActions();
554
+ notify();
555
+ const tab = activeTab();
556
+ if (tab && tab.editor) {
557
+ setTimeout(() => {
558
+ tab.editor.view.focus();
559
+ tab.editor.view.requestMeasure();
560
+ }, 0);
561
+ }
562
+ }
563
+
564
+ async function closeTab(id) {
565
+ const idx = tabs.findIndex((x) => x.id === id);
566
+ if (idx < 0) return;
567
+ const tab = tabs[idx];
568
+ if (tab.dirty) {
569
+ const ok = await Modal.confirm(t("workspace.dirtyCloseConfirm"));
570
+ if (!ok) return;
571
+ }
572
+ if (tab.editor) tab.editor.destroy();
573
+ if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
574
+ tab.el.remove();
575
+ tabs.splice(idx, 1);
576
+ if (activeId === id) {
577
+ const next = tabs[Math.min(idx, tabs.length - 1)];
578
+ activate(next ? next.id : null);
579
+ } else {
580
+ rebuildTabbar();
581
+ }
582
+ notify();
583
+ }
584
+
585
+ function syncDirty(tab) {
586
+ rebuildTabbar();
587
+ updateActions();
588
+ }
589
+
590
+ async function doSave(tab) {
591
+ if (!tab.editor) return;
592
+ try {
593
+ await Workspace.saveFileText(tab.entry, tab.editor.getContent());
594
+ tab.dirty = false;
595
+ syncDirty(tab);
596
+ Modal.toast(t("workspace.saved"), "info");
597
+ } catch (err) {
598
+ console.error("save failed:", err);
599
+ Modal.toast(t("workspace.saveFailed"), "error");
600
+ }
601
+ }
602
+
603
+ function togglePreview(tab) {
604
+ tab.previewMode = !tab.previewMode;
605
+ tab.el.dataset.mode = tab.previewMode ? "preview" : "edit";
606
+ if (tab.previewMode && tab.editor) {
607
+ tab.previewEl.innerHTML = CodeEditor.renderMarkdown(tab.editor.getContent());
608
+ }
609
+ updateActions();
610
+ }
611
+
612
+ function buildTextTab(entry, text) {
613
+ const pane = el("div", "wv-pane wv-pane--text");
614
+ pane.dataset.mode = "edit";
615
+
616
+ const lang = CodeEditor.detectLanguage(entry.name);
617
+ const isMd = lang === "markdown";
618
+
619
+ const code = el("div", "wv-code");
620
+ pane.appendChild(code);
621
+
622
+ const tab = {
623
+ id: entry.path,
624
+ entry,
625
+ kind: "text",
626
+ el: pane,
627
+ dirty: false,
628
+ isMd,
629
+ previewMode: false,
630
+ previewEl: null,
631
+ editor: null,
632
+ imageUrl: null,
633
+ };
634
+
635
+ if (isMd) {
636
+ const preview = el("div", "wv-md-preview code-editor-markdown-preview");
637
+ pane.appendChild(preview);
638
+ tab.previewEl = preview;
639
+ }
640
+
641
+ tab.editor = CodeEditor.createInline(code, {
642
+ content: text,
643
+ language: lang,
644
+ onChange: () => {
645
+ if (!tab.dirty) {
646
+ tab.dirty = true;
647
+ syncDirty(tab);
648
+ }
649
+ },
650
+ onSave: () => doSave(tab),
651
+ });
652
+
653
+ return tab;
654
+ }
655
+
656
+ function buildImageTab(entry, url) {
657
+ const pane = el("div", "wv-pane wv-pane--image");
658
+ const img = document.createElement("img");
659
+ img.alt = entry.name;
660
+ img.src = url;
661
+ pane.appendChild(img);
662
+ return {
663
+ id: entry.path,
664
+ entry,
665
+ kind: "image",
666
+ el: pane,
667
+ dirty: false,
668
+ isMd: false,
669
+ previewMode: false,
670
+ previewEl: null,
671
+ editor: null,
672
+ imageUrl: url,
673
+ };
674
+ }
675
+
676
+ async function openTab(entry) {
677
+ ensureAsideWidth();
678
+ const existing = tabs.find((x) => x.id === entry.path);
679
+ if (existing) {
680
+ activate(existing.id);
681
+ return;
682
+ }
683
+ const kind = CodeEditor.fileKind(entry.name);
684
+ if (kind === "binary") {
685
+ Modal.toast(t("workspace.previewUnsupported"), "info");
686
+ return;
687
+ }
688
+ let tab = null;
689
+ try {
690
+ if (kind === "image") {
691
+ const blob = await Workspace.fetchFileBlob(entry);
692
+ tab = buildImageTab(entry, URL.createObjectURL(blob));
693
+ } else {
694
+ const text = await Workspace.fetchFileText(entry);
695
+ tab = buildTextTab(entry, text);
696
+ }
697
+ } catch (err) {
698
+ console.error("preview failed:", err);
699
+ Modal.toast(t("workspace.previewFailed"), "error");
700
+ return;
701
+ }
702
+ tabs.push(tab);
703
+ content.appendChild(tab.el);
704
+ activate(tab.id);
705
+ notify();
706
+ }
707
+
708
+ previewBtn.addEventListener("click", () => {
709
+ const tab = activeTab();
710
+ if (tab && tab.isMd) togglePreview(tab);
711
+ });
712
+ copyBtn.addEventListener("click", async () => {
713
+ const tab = activeTab();
714
+ if (!tab || !tab.editor) return;
715
+ try {
716
+ await navigator.clipboard.writeText(tab.editor.getContent());
717
+ Modal.toast(t("workspace.copied"), "info");
718
+ } catch (err) {
719
+ Modal.toast(t("workspace.copyFailed"), "error");
720
+ }
721
+ });
722
+ downloadBtn.addEventListener("click", () => {
723
+ const tab = activeTab();
724
+ if (tab) downloadFile(tab.entry);
725
+ });
726
+ saveBtn.addEventListener("click", () => {
727
+ const tab = activeTab();
728
+ if (tab) doSave(tab);
729
+ });
730
+
731
+ function destroy() {
732
+ tabs.forEach((tab) => {
733
+ if (tab.editor) tab.editor.destroy();
734
+ if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
735
+ });
736
+ tabs.length = 0;
737
+ activeId = null;
738
+ }
739
+
740
+ return { root, openTab, destroy };
741
+ }
742
+
743
+ // Draggable split between the file tree and the viewer. The tree width is a
744
+ // CSS variable so a drag just rewrites it; the handle only shows once a file
745
+ // is open (mirrors the has-open-files class on .wv-root). Width persists.
746
+ const TREE_MIN_W = 168;
747
+ const TREE_MAX_W = 460;
748
+ const TREE_WIDTH_KEY = "clacky.workspace.treeWidth";
749
+
750
+ function initTreeResize(treePane, handle) {
751
+ try {
752
+ const saved = parseFloat(localStorage.getItem(TREE_WIDTH_KEY));
753
+ if (saved >= TREE_MIN_W && saved <= TREE_MAX_W) {
754
+ treePane.style.setProperty("--wv-tree-width", saved + "px");
755
+ }
756
+ } catch (_e) { /* ignore */ }
757
+
758
+ let dragging = false;
759
+ let startX = 0;
760
+ let startW = 0;
761
+
762
+ const onMove = (e) => {
763
+ if (!dragging) return;
764
+ const w = Math.min(TREE_MAX_W, Math.max(TREE_MIN_W, startW + (e.clientX - startX)));
765
+ treePane.style.setProperty("--wv-tree-width", w + "px");
766
+ };
767
+ const onUp = () => {
768
+ if (!dragging) return;
769
+ dragging = false;
770
+ document.body.style.cursor = "";
771
+ document.body.style.userSelect = "";
772
+ document.removeEventListener("mousemove", onMove);
773
+ document.removeEventListener("mouseup", onUp);
774
+ const w = treePane.getBoundingClientRect().width;
775
+ try { localStorage.setItem(TREE_WIDTH_KEY, String(Math.round(w))); } catch (_e) { /* ignore */ }
776
+ };
777
+
778
+ handle.addEventListener("mousedown", (e) => {
779
+ e.preventDefault();
780
+ dragging = true;
781
+ startX = e.clientX;
782
+ startW = treePane.getBoundingClientRect().width;
783
+ document.body.style.cursor = "col-resize";
784
+ document.body.style.userSelect = "none";
785
+ document.addEventListener("mousemove", onMove);
786
+ document.addEventListener("mouseup", onUp);
787
+ });
788
+ }
789
+
790
+ // Build the Files tab body for the current session: file tree on the left,
791
+ // tabbed viewer on the right. Mutates `container` and returns a destroy()
792
+ // that frees CodeMirror instances and blob URLs when the tab is re-rendered
793
+ // or the session switches.
794
+ function mount(container, _ctx) {
795
+ const root = el("div", "wv-root");
796
+
797
+ const treePane = el("div", "wv-tree-pane");
798
+
799
+ const bar = el("div", "wt-bar");
800
+
801
+ const searchWrap = el("div", "wt-search");
802
+ const searchIcon = el("span", "wt-search-icon");
803
+ searchIcon.innerHTML = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>';
804
+ const searchInput = el("input", "wt-search-input");
805
+ searchInput.type = "text";
806
+ searchInput.placeholder = t("workspace.search");
807
+ searchInput.setAttribute("aria-label", t("workspace.search"));
808
+ const searchClear = el("button", "wt-search-clear");
809
+ searchClear.type = "button";
810
+ searchClear.title = t("workspace.closeTab");
811
+ searchClear.innerHTML = ICON_CLOSE;
812
+ searchWrap.appendChild(searchIcon);
813
+ searchWrap.appendChild(searchInput);
814
+ searchWrap.appendChild(searchClear);
815
+
816
+ const refresh = el("button", "wt-bar-btn");
284
817
  refresh.type = "button";
285
- refresh.className = "wt-bar-btn";
286
- refresh.textContent = t("workspace.refresh");
287
- bar.appendChild(hint);
818
+ refresh.title = t("workspace.refresh");
819
+ refresh.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg>';
820
+ bar.appendChild(searchWrap);
288
821
  bar.appendChild(refresh);
289
822
 
290
- const tree = document.createElement("div");
291
- tree.className = "wt-tree";
823
+ const tree = el("div", "wt-tree");
292
824
  tree.setAttribute("role", "tree");
825
+ treeState.treeEl = tree;
826
+
827
+ const searchList = el("div", "wt-search-list");
828
+ searchList.style.display = "none";
829
+
830
+ tree.addEventListener("mouseover", (e) => {
831
+ const row = e.target.closest(".wt-row");
832
+ if (row) updateIndentGuides(row);
833
+ });
834
+ tree.addEventListener("mouseleave", () => updateIndentGuides(null));
835
+
836
+ const viewer = createViewer(({ count, activeId }) => {
837
+ root.classList.toggle("has-open-files", count > 0);
838
+ setActiveFilePath(activeId);
839
+ });
840
+
841
+ // ── File search ─────────────────────────────────────────────────────
842
+
843
+ // node_modules/.git hold thousands of vendored files nobody searches for.
844
+ const SKIP_DIRS = { node_modules: true, ".git": true };
845
+
846
+ async function buildIndex() {
847
+ if (treeState.indexPromise) return treeState.indexPromise;
848
+ treeState.indexPromise = (async () => {
849
+ const files = [];
850
+ const walk = async (dirPath) => {
851
+ let entries;
852
+ try { entries = await Workspace.fetchEntries(dirPath); } catch (_e) { return; }
853
+ await Promise.all(entries.map(async (entry) => {
854
+ if (entry.type === "dir") {
855
+ if (!SKIP_DIRS[entry.name]) await walk(entry.path);
856
+ } else {
857
+ files.push(entry);
858
+ }
859
+ }));
860
+ };
861
+ await walk("");
862
+ return files;
863
+ })();
864
+ return treeState.indexPromise;
865
+ }
866
+
867
+ function escapeHtml(s) {
868
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
869
+ }
870
+
871
+ function highlightMatch(text, q) {
872
+ const idx = text.toLowerCase().indexOf(q.toLowerCase());
873
+ if (idx < 0) return escapeHtml(text);
874
+ return escapeHtml(text.slice(0, idx)) +
875
+ '<mark class="wt-mark">' + escapeHtml(text.slice(idx, idx + q.length)) + "</mark>" +
876
+ escapeHtml(text.slice(idx + q.length));
877
+ }
878
+
879
+ function showTree() {
880
+ tree.style.display = "";
881
+ searchList.style.display = "none";
882
+ searchList.replaceChildren();
883
+ searchClear.style.display = "none";
884
+ }
885
+
886
+ function renderSearchResults(files, q) {
887
+ tree.style.display = "none";
888
+ searchList.style.display = "";
889
+ searchList.replaceChildren();
890
+ if (!files.length) {
891
+ const no = el("div", "wt-empty");
892
+ no.textContent = t("workspace.noResults");
893
+ searchList.appendChild(no);
894
+ return;
895
+ }
896
+ const frag = document.createDocumentFragment();
897
+ for (const f of files) {
898
+ const row = el("div", "wt-row wt-search-row");
899
+ row.title = f.path;
900
+ const icon = el("span", "wt-icon");
901
+ icon.innerHTML = fileIconSvg(f.name);
902
+ const name = el("span", "wt-name");
903
+ name.innerHTML = highlightMatch(f.name, q);
904
+ const dir = el("span", "wt-search-dir");
905
+ dir.textContent = f.path.includes("/") ? f.path.slice(0, f.path.lastIndexOf("/")) : "";
906
+ row.appendChild(icon);
907
+ row.appendChild(name);
908
+ row.appendChild(dir);
909
+ row.addEventListener("click", () => viewer.openTab(f));
910
+ frag.appendChild(row);
911
+ }
912
+ searchList.appendChild(frag);
913
+ }
293
914
 
294
- refresh.addEventListener("click", () => loadRoot(tree));
915
+ async function runSearch(q) {
916
+ searchClear.style.display = q ? "inline-flex" : "none";
917
+ if (!q) { showTree(); return; }
918
+ const files = await buildIndex();
919
+ if (searchInput.value.trim() !== q) return;
920
+ const ql = q.toLowerCase();
921
+ const scored = [];
922
+ for (const f of files) {
923
+ const nameL = f.name.toLowerCase();
924
+ const pathL = f.path.toLowerCase();
925
+ let score;
926
+ if (nameL.startsWith(ql)) score = 0;
927
+ else if (nameL.includes(ql)) score = 1;
928
+ else if (pathL.includes(ql)) score = 2;
929
+ else continue;
930
+ scored.push({ f, score, depth: f.path.split("/").length });
931
+ }
932
+ scored.sort((a, b) => a.score - b.score || a.depth - b.depth || a.f.path.localeCompare(b.f.path));
933
+ renderSearchResults(scored.map((s) => s.f).slice(0, 200), q);
934
+ }
935
+
936
+ let searchTimer = null;
937
+ searchInput.addEventListener("input", () => {
938
+ clearTimeout(searchTimer);
939
+ searchTimer = setTimeout(() => { runSearch(searchInput.value.trim()); }, 200);
940
+ });
941
+ searchInput.addEventListener("keydown", (e) => {
942
+ if (e.key === "Escape" && searchInput.value) {
943
+ searchInput.value = "";
944
+ showTree();
945
+ }
946
+ });
947
+ searchClear.addEventListener("click", () => {
948
+ searchInput.value = "";
949
+ searchInput.focus();
950
+ showTree();
951
+ });
952
+
953
+ refresh.addEventListener("click", () => {
954
+ treeState.indexPromise = null;
955
+ searchInput.value = "";
956
+ showTree();
957
+ loadRoot(tree, viewer.openTab);
958
+ });
295
959
 
296
- wrap.appendChild(bar);
297
- wrap.appendChild(tree);
298
- loadRoot(tree);
299
- return wrap;
960
+ treePane.appendChild(bar);
961
+ treePane.appendChild(tree);
962
+ treePane.appendChild(searchList);
963
+
964
+ const resizeHandle = el("div", "wv-tree-resize");
965
+ initTreeResize(treePane, resizeHandle);
966
+
967
+ root.appendChild(treePane);
968
+ root.appendChild(resizeHandle);
969
+ root.appendChild(viewer.root);
970
+ container.appendChild(root);
971
+
972
+ loadRoot(tree, viewer.openTab);
973
+ return {
974
+ destroy() {
975
+ clearTimeout(searchTimer);
976
+ treeState.treeEl = null;
977
+ treeState.activePath = null;
978
+ treeState.indexPromise = null;
979
+ lastGuidedRow = null;
980
+ viewer.destroy();
981
+ },
982
+ };
300
983
  }
301
984
 
302
- return { renderFilesTab };
985
+ return { mount };
303
986
  })();
304
987
 
305
- // Files is a built-in tab: visible for every session, after the agent-scoped
306
- // panels (git/time-machine use orders 10/20).
988
+ // Files is a built-in tab: visible for every session, after ext-studio
989
+ // publish (20) and before git (30) / time-machine (40).
307
990
  if (window.Clacky && Clacky.ext) {
308
- Clacky.ext.ui.mountBuiltin("session.aside", (ctx) => WorkspaceView.renderFilesTab(ctx), {
309
- order: 40,
991
+ Clacky.ext.ui.mountBuiltin("session.aside", (container, ctx) => {
992
+ const viewer = WorkspaceView.mount(container, ctx);
993
+ return () => viewer.destroy();
994
+ }, {
995
+ order: 25,
310
996
  tab: { id: "files", label: () => (typeof I18n !== "undefined" ? I18n.t("workspace.title") : "Files") },
311
997
  });
312
998
  }