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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
// ── Workspace · view
|
|
1
|
+
// ── Workspace · view - Files tab (tree + inline tabbed viewer) ─────────────
|
|
2
2
|
//
|
|
3
|
-
// Renders the working-directory file tree
|
|
4
|
-
// aside.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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">${
|
|
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">${
|
|
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">${
|
|
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
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
286
|
-
refresh.
|
|
287
|
-
bar.appendChild(
|
|
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 =
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 {
|
|
985
|
+
return { mount };
|
|
303
986
|
})();
|
|
304
987
|
|
|
305
|
-
// Files is a built-in tab: visible for every session, after
|
|
306
|
-
//
|
|
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) =>
|
|
309
|
-
|
|
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
|
}
|