openclacky 1.5.19 → 1.5.21
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 +95 -0
- data/README.md +19 -1
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +17 -3
- data/lib/clacky/agent/message_compressor_helper.rb +36 -2
- data/lib/clacky/agent/session_serializer.rb +45 -4
- data/lib/clacky/agent.rb +135 -55
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +10 -5
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +5 -4
- data/lib/clacky/client.rb +12 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +45 -4
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +38 -138
- data/lib/clacky/search_config.rb +47 -13
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/http_server.rb +285 -53
- data/lib/clacky/server/model_prices.rb +10 -5
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +37 -7
- data/lib/clacky/server/web_ui_controller.rb +72 -0
- data/lib/clacky/session_manager.rb +28 -6
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/model_pricing.rb +242 -39
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1158 -249
- data/lib/clacky/web/app.js +82 -13
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/code-editor.js +6 -2
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +92 -97
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/components/sidebar.js +6 -2
- data/lib/clacky/web/features/archive/store.js +196 -0
- data/lib/clacky/web/features/archive/view.js +409 -0
- data/lib/clacky/web/features/backup/view.js +17 -7
- data/lib/clacky/web/features/billing/view.js +84 -10
- data/lib/clacky/web/features/channels/view.js +75 -29
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/new-session/view.js +1 -0
- data/lib/clacky/web/features/trash/store.js +14 -11
- data/lib/clacky/web/features/trash/view.js +66 -13
- data/lib/clacky/web/features/workspace/view.js +421 -16
- data/lib/clacky/web/i18n.js +242 -70
- data/lib/clacky/web/index.html +270 -117
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +609 -267
- data/lib/clacky/web/settings.js +186 -32
- data/lib/clacky/web/ws-dispatcher.js +33 -21
- data/lib/clacky.rb +4 -2
- metadata +6 -1
|
@@ -33,6 +33,14 @@ const WorkspaceView = (() => {
|
|
|
33
33
|
const ICON_PANEL_LEFT = '<svg xmlns="http://www.w3.org/2000/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="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
|
34
34
|
const ICON_ARROW_LEFT = '<svg xmlns="http://www.w3.org/2000/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 12H5"/><path d="m12 19-7-7 7-7"/></svg>';
|
|
35
35
|
const ICON_COLUMNS = '<svg xmlns="http://www.w3.org/2000/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="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>';
|
|
36
|
+
const ICON_COPY_REL_PATH = '<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>';
|
|
37
|
+
const ICON_CLOSE_MD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
|
|
38
|
+
const ICON_CLOSE_OTHERS = '<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="M3 7h8"/><path d="M3 17h8"/><path d="m20 8-5 5"/><path d="m15 8 5 5"/></svg>';
|
|
39
|
+
const ICON_CLOSE_RIGHT = '<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="M4 4v16"/><path d="m20 8-6 6"/><path d="m14 8 6 6"/></svg>';
|
|
40
|
+
const ICON_CLOSE_ALL = '<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="3" y="3" width="18" height="18" rx="2"/><path d="m9 9 6 6"/><path d="m15 9-6 6"/></svg>';
|
|
41
|
+
const ICON_ZOOM_IN = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>';
|
|
42
|
+
const ICON_ZOOM_OUT = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/><path d="M8 11h6"/></svg>';
|
|
43
|
+
const ICON_ZOOM_FIT = '<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="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M16 3h3a2 2 0 0 1 2 2v3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/><path d="M8 21H5a2 2 0 0 1-2-2v-3"/></svg>';
|
|
36
44
|
|
|
37
45
|
function el(tag, cls) {
|
|
38
46
|
const d = document.createElement(tag);
|
|
@@ -135,6 +143,15 @@ const WorkspaceView = (() => {
|
|
|
135
143
|
'<path fill="#c3ccd4" d="M9.7 1.5v3.3H13z"/>'
|
|
136
144
|
);
|
|
137
145
|
|
|
146
|
+
const ICON_VIDEO = svgWrap(
|
|
147
|
+
'<rect x="1.5" y="3" width="13" height="10" rx="2" fill="#5c8ee6"/>' +
|
|
148
|
+
'<path d="m6.5 5.8 4 2.2-4 2.2z" fill="#fff"/>'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const ICON_AUDIO = svgWrap(
|
|
152
|
+
'<path d="M6.5 3.5v7.2a2.4 2.4 0 1 1-1.2-2.1V5l6-1.5v5.7a2.4 2.4 0 1 1-1.2-2.1V2z" fill="#38a6a5"/>'
|
|
153
|
+
);
|
|
154
|
+
|
|
138
155
|
function extOf(name) {
|
|
139
156
|
const n = name.toLowerCase();
|
|
140
157
|
if (n.startsWith(".git")) return "gitfile";
|
|
@@ -167,6 +184,8 @@ const WorkspaceView = (() => {
|
|
|
167
184
|
svg: imgIcon("#ffb13b"),
|
|
168
185
|
png: imgIcon("#a074c4"), jpg: imgIcon("#a074c4"), jpeg: imgIcon("#a074c4"),
|
|
169
186
|
gif: imgIcon("#a074c4"), webp: imgIcon("#a074c4"), ico: imgIcon("#a074c4"), bmp: imgIcon("#a074c4"),
|
|
187
|
+
mp4: ICON_VIDEO, webm: ICON_VIDEO, mov: ICON_VIDEO,
|
|
188
|
+
mp3: ICON_AUDIO, wav: ICON_AUDIO, ogg: ICON_AUDIO, aac: ICON_AUDIO, flac: ICON_AUDIO, m4a: ICON_AUDIO,
|
|
170
189
|
pdf: boxIcon("P", "#e04b4b"),
|
|
171
190
|
zip: boxIcon("Z", "#dba15d"), tar: boxIcon("Z", "#dba15d"), gz: boxIcon("Z", "#dba15d"),
|
|
172
191
|
"7z": boxIcon("Z", "#dba15d"), rar: boxIcon("Z", "#dba15d"), dmg: boxIcon("Z", "#dba15d"), iso: boxIcon("Z", "#dba15d"),
|
|
@@ -307,8 +326,6 @@ const WorkspaceView = (() => {
|
|
|
307
326
|
function showContextMenu(e, entry) {
|
|
308
327
|
closeContextMenu();
|
|
309
328
|
|
|
310
|
-
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>';
|
|
311
|
-
|
|
312
329
|
const downloadItem = entry.type === "file" ? `
|
|
313
330
|
<div class="session-actions-menu-item" data-action="download">
|
|
314
331
|
<span class="session-actions-menu-icon">${ICON_DOWNLOAD}</span>
|
|
@@ -334,7 +351,7 @@ const WorkspaceView = (() => {
|
|
|
334
351
|
<span class="session-actions-menu-label">${t("workspace.copyPath")}</span>
|
|
335
352
|
</div>
|
|
336
353
|
<div class="session-actions-menu-item" data-action="copyrelpath">
|
|
337
|
-
<span class="session-actions-menu-icon">${
|
|
354
|
+
<span class="session-actions-menu-icon">${ICON_COPY_REL_PATH}</span>
|
|
338
355
|
<span class="session-actions-menu-label">${t("workspace.copyRelPath")}</span>
|
|
339
356
|
</div>
|
|
340
357
|
${downloadItem}
|
|
@@ -663,6 +680,52 @@ const WorkspaceView = (() => {
|
|
|
663
680
|
|
|
664
681
|
const VIEWER_ASIDE_WIDTH = 700;
|
|
665
682
|
|
|
683
|
+
// Image zoom is expressed against the picture's own pixels, so 100% always
|
|
684
|
+
// means "one screen pixel per image pixel" whatever the pane is doing.
|
|
685
|
+
const IMAGE_MIN_ZOOM = 0.1;
|
|
686
|
+
const IMAGE_MAX_ZOOM = 8;
|
|
687
|
+
const IMAGE_ZOOM_STEP = 1.25;
|
|
688
|
+
|
|
689
|
+
// Open files outlive a reload: the strip is rebuilt from a per-session record
|
|
690
|
+
// in localStorage, so refreshing the page (or coming back to a session) lands
|
|
691
|
+
// on the same files. Paths only — every tab body is fetched from disk again.
|
|
692
|
+
const OPEN_TABS_KEY = "clacky.workspace.openTabs";
|
|
693
|
+
const OPEN_TABS_MAX_SESSIONS = 12;
|
|
694
|
+
|
|
695
|
+
function readOpenTabs() {
|
|
696
|
+
try {
|
|
697
|
+
const raw = localStorage.getItem(OPEN_TABS_KEY);
|
|
698
|
+
const data = raw ? JSON.parse(raw) : null;
|
|
699
|
+
return data && typeof data === "object" ? data : {};
|
|
700
|
+
} catch (_e) {
|
|
701
|
+
return {};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Least-recently-used sessions are dropped so a long-lived browser does not
|
|
706
|
+
// keep a list for every session it has ever opened.
|
|
707
|
+
function writeOpenTabs(data) {
|
|
708
|
+
const ids = Object.keys(data);
|
|
709
|
+
if (ids.length > OPEN_TABS_MAX_SESSIONS) {
|
|
710
|
+
ids.sort((a, b) => ((data[b] && data[b].ts) || 0) - ((data[a] && data[a].ts) || 0));
|
|
711
|
+
ids.slice(OPEN_TABS_MAX_SESSIONS).forEach((id) => { delete data[id]; });
|
|
712
|
+
}
|
|
713
|
+
try { localStorage.setItem(OPEN_TABS_KEY, JSON.stringify(data)); } catch (_e) { /* quota / private mode */ }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Reopen what this session had on screen before the reload. A path that no
|
|
717
|
+
// longer opens is skipped without a message: the record is a convenience, not
|
|
718
|
+
// a promise that the file is still there.
|
|
719
|
+
async function restoreOpenTabs(viewer) {
|
|
720
|
+
const sessionId = Workspace.state.sessionId;
|
|
721
|
+
const saved = sessionId ? readOpenTabs()[sessionId] : null;
|
|
722
|
+
if (!saved || !Array.isArray(saved.tabs)) return;
|
|
723
|
+
for (const path of saved.tabs) {
|
|
724
|
+
if (typeof path === "string" && path) await viewer.openTab(toEntry(path), { silent: true });
|
|
725
|
+
}
|
|
726
|
+
if (saved.active) viewer.activate(saved.active);
|
|
727
|
+
}
|
|
728
|
+
|
|
666
729
|
// Active viewer instance (one per mounted Files tab). Kept at module level
|
|
667
730
|
// so openFile() — called from chat file:// link clicks — can reach the
|
|
668
731
|
// viewer without waiting for a DOM query or a custom event round-trip.
|
|
@@ -695,7 +758,32 @@ const WorkspaceView = (() => {
|
|
|
695
758
|
const tabs = [];
|
|
696
759
|
let activeId = null;
|
|
697
760
|
let releaseAsideWidth = null;
|
|
698
|
-
const notify = () => {
|
|
761
|
+
const notify = () => {
|
|
762
|
+
if (onTabsChanged) onTabsChanged({ count: tabs.length, activeId });
|
|
763
|
+
persistTabs();
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
// Derived from the strip rather than patched in place, so closing the last
|
|
767
|
+
// tab forgets the session instead of leaving a stale list behind. Binary
|
|
768
|
+
// tabs stay out: reopening one is a request to launch an external
|
|
769
|
+
// application, which a page reload must not do on its own.
|
|
770
|
+
function persistTabs() {
|
|
771
|
+
const sessionId = Workspace.state.sessionId;
|
|
772
|
+
if (!sessionId) return;
|
|
773
|
+
const data = readOpenTabs();
|
|
774
|
+
const paths = tabs.filter((x) => x.kind !== "binary").map((x) => x.entry.path);
|
|
775
|
+
if (!paths.length) {
|
|
776
|
+
delete data[sessionId];
|
|
777
|
+
} else {
|
|
778
|
+
const active = tabs.find((x) => x.id === activeId);
|
|
779
|
+
data[sessionId] = {
|
|
780
|
+
tabs: paths,
|
|
781
|
+
active: active && active.kind !== "binary" ? active.entry.path : paths[paths.length - 1],
|
|
782
|
+
ts: Date.now(),
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
writeOpenTabs(data);
|
|
786
|
+
}
|
|
699
787
|
|
|
700
788
|
function ensureAsideWidth() {
|
|
701
789
|
if (releaseAsideWidth || !window.Clacky || !Clacky.Aside || !Clacky.Aside.requestWidth) return;
|
|
@@ -833,6 +921,10 @@ const WorkspaceView = (() => {
|
|
|
833
921
|
btn.addEventListener("auxclick", (e) => {
|
|
834
922
|
if (e.button === 1) closeTab(tab.id);
|
|
835
923
|
});
|
|
924
|
+
btn.addEventListener("contextmenu", (e) => {
|
|
925
|
+
e.preventDefault();
|
|
926
|
+
showTabContextMenu(e, tab);
|
|
927
|
+
});
|
|
836
928
|
|
|
837
929
|
tabsEl.appendChild(btn);
|
|
838
930
|
});
|
|
@@ -876,8 +968,15 @@ const WorkspaceView = (() => {
|
|
|
876
968
|
}
|
|
877
969
|
|
|
878
970
|
function activate(id) {
|
|
971
|
+
// A restored record can name a file that never opened; dropping the id
|
|
972
|
+
// keeps the strip from going fully inactive.
|
|
973
|
+
if (id != null && !tabs.some((x) => x.id === id)) return;
|
|
879
974
|
activeId = id;
|
|
880
|
-
tabs.forEach((tab) =>
|
|
975
|
+
tabs.forEach((tab) => {
|
|
976
|
+
const active = tab.id === id;
|
|
977
|
+
tab.el.classList.toggle("active", active);
|
|
978
|
+
if (!active && tab.mediaEl) tab.mediaEl.pause();
|
|
979
|
+
});
|
|
881
980
|
rebuildTabbar();
|
|
882
981
|
updateActions();
|
|
883
982
|
notify();
|
|
@@ -890,6 +989,18 @@ const WorkspaceView = (() => {
|
|
|
890
989
|
}
|
|
891
990
|
}
|
|
892
991
|
|
|
992
|
+
function disposeTab(tab) {
|
|
993
|
+
if (tab.editor) tab.editor.destroy();
|
|
994
|
+
if (tab.zoomObserver) tab.zoomObserver.disconnect();
|
|
995
|
+
if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
|
|
996
|
+
if (tab.mediaEl) {
|
|
997
|
+
tab.mediaEl.pause();
|
|
998
|
+
tab.mediaEl.removeAttribute("src");
|
|
999
|
+
tab.mediaEl.load();
|
|
1000
|
+
}
|
|
1001
|
+
tab.el.remove();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
893
1004
|
async function closeTab(id) {
|
|
894
1005
|
const idx = tabs.findIndex((x) => x.id === id);
|
|
895
1006
|
if (idx < 0) return;
|
|
@@ -898,9 +1009,7 @@ const WorkspaceView = (() => {
|
|
|
898
1009
|
const ok = await Modal.confirm(t("workspace.dirtyCloseConfirm"));
|
|
899
1010
|
if (!ok) return;
|
|
900
1011
|
}
|
|
901
|
-
|
|
902
|
-
if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
|
|
903
|
-
tab.el.remove();
|
|
1012
|
+
disposeTab(tab);
|
|
904
1013
|
tabs.splice(idx, 1);
|
|
905
1014
|
if (activeId === id) {
|
|
906
1015
|
const next = tabs[Math.min(idx, tabs.length - 1)];
|
|
@@ -912,6 +1021,125 @@ const WorkspaceView = (() => {
|
|
|
912
1021
|
notify();
|
|
913
1022
|
}
|
|
914
1023
|
|
|
1024
|
+
// Closing several tabs at once is one gesture, so ask once for the whole
|
|
1025
|
+
// batch instead of once per unsaved file. The active tab only moves when it
|
|
1026
|
+
// was part of the batch.
|
|
1027
|
+
async function closeTabs(doomed) {
|
|
1028
|
+
if (!doomed.length) return;
|
|
1029
|
+
const dirtyCount = doomed.filter((x) => x.dirty).length;
|
|
1030
|
+
if (dirtyCount > 0) {
|
|
1031
|
+
const ok = await Modal.confirm(t("workspace.dirtyCloseBatchConfirm", { count: dirtyCount }));
|
|
1032
|
+
if (!ok) return;
|
|
1033
|
+
}
|
|
1034
|
+
const doomedIds = doomed.map((x) => x.id);
|
|
1035
|
+
doomed.forEach(disposeTab);
|
|
1036
|
+
for (let i = tabs.length - 1; i >= 0; i--) {
|
|
1037
|
+
if (doomedIds.indexOf(tabs[i].id) >= 0) tabs.splice(i, 1);
|
|
1038
|
+
}
|
|
1039
|
+
const nextId = doomedIds.indexOf(activeId) >= 0
|
|
1040
|
+
? (tabs.length ? tabs[tabs.length - 1].id : null)
|
|
1041
|
+
: activeId;
|
|
1042
|
+
activate(nextId);
|
|
1043
|
+
if (tabs.length === 0) restoreAsideWidth();
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function closeAllTabs() {
|
|
1047
|
+
return closeTabs(tabs.slice());
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function closeOtherTabs(tab) {
|
|
1051
|
+
return closeTabs(tabs.filter((x) => x.id !== tab.id));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function closeTabsToTheRight(tab) {
|
|
1055
|
+
return closeTabs(tabs.slice(tabs.findIndex((x) => x.id === tab.id) + 1));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Right-click on a tab. The file actions are the ones the tree already
|
|
1059
|
+
// offers for the same entry; closing is the tab's own, so the menu is the
|
|
1060
|
+
// only place it can also act on tabs the pointer is not hovering.
|
|
1061
|
+
function showTabContextMenu(e, tab) {
|
|
1062
|
+
closeContextMenu();
|
|
1063
|
+
const entry = tab.entry;
|
|
1064
|
+
// "Close to the Right" has nothing to act on when the tab is the last one;
|
|
1065
|
+
// keep the entry in place so the menu does not shift, but grey it out.
|
|
1066
|
+
const hasTabsToTheRight = tabs.indexOf(tab) < tabs.length - 1;
|
|
1067
|
+
|
|
1068
|
+
const menu = document.createElement("div");
|
|
1069
|
+
menu.className = "wt-context-menu session-context-menu";
|
|
1070
|
+
menu.innerHTML = `
|
|
1071
|
+
<div class="session-actions-menu-item" data-action="closetab">
|
|
1072
|
+
<span class="session-actions-menu-icon">${ICON_CLOSE_MD}</span>
|
|
1073
|
+
<span class="session-actions-menu-label">${t("workspace.closeTab")}</span>
|
|
1074
|
+
</div>
|
|
1075
|
+
<div class="session-actions-menu-item" data-action="closeothers">
|
|
1076
|
+
<span class="session-actions-menu-icon">${ICON_CLOSE_OTHERS}</span>
|
|
1077
|
+
<span class="session-actions-menu-label">${t("workspace.closeOthers")}</span>
|
|
1078
|
+
</div>
|
|
1079
|
+
<div class="session-actions-menu-item${hasTabsToTheRight ? "" : " session-actions-menu-item--disabled"}" data-action="closeright">
|
|
1080
|
+
<span class="session-actions-menu-icon">${ICON_CLOSE_RIGHT}</span>
|
|
1081
|
+
<span class="session-actions-menu-label">${t("workspace.closeToTheRight")}</span>
|
|
1082
|
+
</div>
|
|
1083
|
+
<div class="session-actions-menu-item" data-action="closealltabs">
|
|
1084
|
+
<span class="session-actions-menu-icon">${ICON_CLOSE_ALL}</span>
|
|
1085
|
+
<span class="session-actions-menu-label">${t("workspace.closeAllTabs")}</span>
|
|
1086
|
+
</div>
|
|
1087
|
+
<div class="wv-menu-separator"></div>
|
|
1088
|
+
<div class="session-actions-menu-item" data-action="addtochat">
|
|
1089
|
+
<span class="session-actions-menu-icon">${ICON_ADD_TO_CHAT}</span>
|
|
1090
|
+
<span class="session-actions-menu-label">${t("workspace.addToChat")}</span>
|
|
1091
|
+
</div>
|
|
1092
|
+
<div class="session-actions-menu-item" data-action="reveal">
|
|
1093
|
+
<span class="session-actions-menu-icon">${ICON_FOLDER}</span>
|
|
1094
|
+
<span class="session-actions-menu-label">${t("workspace.revealInFinder")}</span>
|
|
1095
|
+
</div>
|
|
1096
|
+
<div class="session-actions-menu-item" data-action="copypath">
|
|
1097
|
+
<span class="session-actions-menu-icon">${ICON_COPY}</span>
|
|
1098
|
+
<span class="session-actions-menu-label">${t("workspace.copyPath")}</span>
|
|
1099
|
+
</div>
|
|
1100
|
+
<div class="session-actions-menu-item" data-action="copyrelpath">
|
|
1101
|
+
<span class="session-actions-menu-icon">${ICON_COPY_REL_PATH}</span>
|
|
1102
|
+
<span class="session-actions-menu-label">${t("workspace.copyRelPath")}</span>
|
|
1103
|
+
</div>
|
|
1104
|
+
<div class="session-actions-menu-item" data-action="download">
|
|
1105
|
+
<span class="session-actions-menu-icon">${ICON_DOWNLOAD}</span>
|
|
1106
|
+
<span class="session-actions-menu-label">${t("workspace.download")}</span>
|
|
1107
|
+
</div>
|
|
1108
|
+
`;
|
|
1109
|
+
|
|
1110
|
+
document.body.appendChild(menu);
|
|
1111
|
+
menu.addEventListener("contextmenu", (ev) => ev.preventDefault());
|
|
1112
|
+
menu.style.position = "fixed";
|
|
1113
|
+
menu.style.top = e.clientY + "px";
|
|
1114
|
+
menu.style.left = e.clientX + "px";
|
|
1115
|
+
requestAnimationFrame(() => {
|
|
1116
|
+
const r = menu.getBoundingClientRect();
|
|
1117
|
+
if (r.right > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 8) + "px";
|
|
1118
|
+
if (r.bottom > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 8) + "px";
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
menu.addEventListener("click", async (ev) => {
|
|
1122
|
+
const item = ev.target.closest(".session-actions-menu-item");
|
|
1123
|
+
if (!item) return;
|
|
1124
|
+
closeContextMenu();
|
|
1125
|
+
if (item.classList.contains("session-actions-menu-item--disabled")) return;
|
|
1126
|
+
const action = item.dataset.action;
|
|
1127
|
+
if (action === "closetab") await closeTab(tab.id);
|
|
1128
|
+
if (action === "closeothers") await closeOtherTabs(tab);
|
|
1129
|
+
if (action === "closeright") await closeTabsToTheRight(tab);
|
|
1130
|
+
if (action === "closealltabs") await closeAllTabs();
|
|
1131
|
+
if (action === "reveal") await revealFile(entry);
|
|
1132
|
+
if (action === "download") await downloadFile(entry);
|
|
1133
|
+
if (action === "copypath") copyPath(entry);
|
|
1134
|
+
if (action === "copyrelpath") copyRelPath(entry);
|
|
1135
|
+
if (action === "addtochat") addToChat(entry);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
setTimeout(() => {
|
|
1139
|
+
document.addEventListener("click", closeContextMenu, { once: true });
|
|
1140
|
+
}, 0);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
915
1143
|
function syncDirty(tab) {
|
|
916
1144
|
rebuildTabbar();
|
|
917
1145
|
updateActions();
|
|
@@ -1001,10 +1229,148 @@ const WorkspaceView = (() => {
|
|
|
1001
1229
|
|
|
1002
1230
|
function buildImageTab(entry, url) {
|
|
1003
1231
|
const pane = el("div", "wv-pane wv-pane--image");
|
|
1232
|
+
const viewport = el("div", "wv-zoom");
|
|
1004
1233
|
const img = document.createElement("img");
|
|
1005
1234
|
img.alt = entry.name;
|
|
1006
1235
|
img.src = url;
|
|
1007
|
-
|
|
1236
|
+
img.draggable = false;
|
|
1237
|
+
viewport.appendChild(img);
|
|
1238
|
+
|
|
1239
|
+
const bar = el("div", "wv-zoom-bar");
|
|
1240
|
+
const outBtn = actionBtn(ICON_ZOOM_OUT, t("workspace.zoomOut"), "wv-zoom-btn");
|
|
1241
|
+
const level = el("span", "wv-zoom-level");
|
|
1242
|
+
const inBtn = actionBtn(ICON_ZOOM_IN, t("workspace.zoomIn"), "wv-zoom-btn");
|
|
1243
|
+
const fitBtn = actionBtn(ICON_ZOOM_FIT, t("workspace.zoomFit"), "wv-zoom-btn");
|
|
1244
|
+
bar.append(outBtn, level, inBtn, fitBtn);
|
|
1245
|
+
pane.append(viewport, bar);
|
|
1246
|
+
|
|
1247
|
+
// The picture keeps its natural size and is scaled by transform, so the
|
|
1248
|
+
// number in the bar is a pixel ratio rather than a share of the pane.
|
|
1249
|
+
// `fit` is the scale that shows all of it; the buttons, the wheel and the
|
|
1250
|
+
// double click all move away from there.
|
|
1251
|
+
let scale = 1;
|
|
1252
|
+
let fit = 1;
|
|
1253
|
+
let x = 0;
|
|
1254
|
+
let y = 0;
|
|
1255
|
+
|
|
1256
|
+
function fitScale() {
|
|
1257
|
+
const vw = viewport.clientWidth;
|
|
1258
|
+
const vh = viewport.clientHeight;
|
|
1259
|
+
// A pane inside a hidden tab measures as zero, and a zero ratio would
|
|
1260
|
+
// park the picture at an invisible size — stay neutral until it is real.
|
|
1261
|
+
if (!vw || !vh) return 1;
|
|
1262
|
+
const nw = img.naturalWidth || vw;
|
|
1263
|
+
const nh = img.naturalHeight || vh;
|
|
1264
|
+
return Math.min(1, vw / nw, vh / nh);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function pan(offsetX, offsetY) {
|
|
1268
|
+
// Nothing to reveal beyond the edges, so an image narrower than the
|
|
1269
|
+
// pane stays centred instead of being dragged off into a corner.
|
|
1270
|
+
const maxX = Math.max(0, (img.naturalWidth * scale - viewport.clientWidth) / 2);
|
|
1271
|
+
const maxY = Math.max(0, (img.naturalHeight * scale - viewport.clientHeight) / 2);
|
|
1272
|
+
x = Math.max(-maxX, Math.min(maxX, offsetX));
|
|
1273
|
+
y = Math.max(-maxY, Math.min(maxY, offsetY));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function render() {
|
|
1277
|
+
pan(x, y);
|
|
1278
|
+
img.style.transform = "translate(" + x + "px, " + y + "px) scale(" + scale + ")";
|
|
1279
|
+
level.textContent = Math.round(scale * 100) + "%";
|
|
1280
|
+
outBtn.disabled = scale <= IMAGE_MIN_ZOOM + 1e-6;
|
|
1281
|
+
inBtn.disabled = scale >= IMAGE_MAX_ZOOM - 1e-6;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Zoom around a point (pane centre by default) so the pixels under the
|
|
1285
|
+
// cursor stay put instead of the picture drifting toward a corner.
|
|
1286
|
+
function zoomAt(target, px, py) {
|
|
1287
|
+
const next = Math.max(IMAGE_MIN_ZOOM, Math.min(IMAGE_MAX_ZOOM, target));
|
|
1288
|
+
if (next === scale) return;
|
|
1289
|
+
const k = next / scale;
|
|
1290
|
+
x = px - k * (px - x);
|
|
1291
|
+
y = py - k * (py - y);
|
|
1292
|
+
scale = next;
|
|
1293
|
+
render();
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function anchorOf(ev) {
|
|
1297
|
+
const rect = viewport.getBoundingClientRect();
|
|
1298
|
+
return [ev.clientX - (rect.left + rect.width / 2), ev.clientY - (rect.top + rect.height / 2)];
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function reset() {
|
|
1302
|
+
fit = fitScale();
|
|
1303
|
+
scale = fit;
|
|
1304
|
+
x = 0;
|
|
1305
|
+
y = 0;
|
|
1306
|
+
render();
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const atFit = () => Math.abs(scale - fit) < 1e-6;
|
|
1310
|
+
|
|
1311
|
+
// A double click is the shortcut for "show me the real pixels" — or, for a
|
|
1312
|
+
// picture that already fits, for "closer than that".
|
|
1313
|
+
const closerTarget = () => (fit < 1 ? 1 : fit * 2);
|
|
1314
|
+
|
|
1315
|
+
inBtn.addEventListener("click", () => zoomAt(scale * IMAGE_ZOOM_STEP, 0, 0));
|
|
1316
|
+
outBtn.addEventListener("click", () => zoomAt(scale / IMAGE_ZOOM_STEP, 0, 0));
|
|
1317
|
+
fitBtn.addEventListener("click", reset);
|
|
1318
|
+
|
|
1319
|
+
// A picture has nothing to scroll, so the wheel belongs to the zoom —
|
|
1320
|
+
// including the pinch gesture a trackpad reports as ctrl+wheel. The
|
|
1321
|
+
// damping keeps one mouse notch (±100) near a quarter turn while the many
|
|
1322
|
+
// small deltas a trackpad sends add up to a glide instead of a jump.
|
|
1323
|
+
viewport.addEventListener("wheel", (ev) => {
|
|
1324
|
+
ev.preventDefault();
|
|
1325
|
+
const anchor = anchorOf(ev);
|
|
1326
|
+
zoomAt(scale * Math.exp(-ev.deltaY * 0.002), anchor[0], anchor[1]);
|
|
1327
|
+
}, { passive: false });
|
|
1328
|
+
|
|
1329
|
+
viewport.addEventListener("dblclick", (ev) => {
|
|
1330
|
+
ev.preventDefault();
|
|
1331
|
+
if (atFit()) {
|
|
1332
|
+
const anchor = anchorOf(ev);
|
|
1333
|
+
zoomAt(closerTarget(), anchor[0], anchor[1]);
|
|
1334
|
+
} else {
|
|
1335
|
+
reset();
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
let drag = null;
|
|
1340
|
+
viewport.addEventListener("pointerdown", (ev) => {
|
|
1341
|
+
if (ev.button) return;
|
|
1342
|
+
drag = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
|
|
1343
|
+
viewport.classList.add("is-panning");
|
|
1344
|
+
viewport.setPointerCapture(ev.pointerId);
|
|
1345
|
+
ev.preventDefault();
|
|
1346
|
+
});
|
|
1347
|
+
viewport.addEventListener("pointermove", (ev) => {
|
|
1348
|
+
if (!drag || ev.pointerId !== drag.id) return;
|
|
1349
|
+
pan(x + ev.clientX - drag.x, y + ev.clientY - drag.y);
|
|
1350
|
+
drag.x = ev.clientX;
|
|
1351
|
+
drag.y = ev.clientY;
|
|
1352
|
+
img.style.transform = "translate(" + x + "px, " + y + "px) scale(" + scale + ")";
|
|
1353
|
+
});
|
|
1354
|
+
const endDrag = (ev) => {
|
|
1355
|
+
if (!drag || ev.pointerId !== drag.id) return;
|
|
1356
|
+
drag = null;
|
|
1357
|
+
viewport.classList.remove("is-panning");
|
|
1358
|
+
};
|
|
1359
|
+
viewport.addEventListener("pointerup", endDrag);
|
|
1360
|
+
viewport.addEventListener("pointercancel", endDrag);
|
|
1361
|
+
|
|
1362
|
+
img.addEventListener("load", reset);
|
|
1363
|
+
if (img.complete) reset();
|
|
1364
|
+
render();
|
|
1365
|
+
|
|
1366
|
+
// Dragging the aside or the window changes the fit ratio, and a tab that
|
|
1367
|
+
// was opened in the background only finds its real size once it is shown —
|
|
1368
|
+
// so refit on every new size, but leave a deliberate zoom alone.
|
|
1369
|
+
const observer = typeof ResizeObserver === "function"
|
|
1370
|
+
? new ResizeObserver(() => { if (atFit()) reset(); })
|
|
1371
|
+
: null;
|
|
1372
|
+
if (observer) observer.observe(viewport);
|
|
1373
|
+
|
|
1008
1374
|
return {
|
|
1009
1375
|
id: entry.path,
|
|
1010
1376
|
entry,
|
|
@@ -1016,6 +1382,7 @@ const WorkspaceView = (() => {
|
|
|
1016
1382
|
previewEl: null,
|
|
1017
1383
|
editor: null,
|
|
1018
1384
|
imageUrl: url,
|
|
1385
|
+
zoomObserver: observer,
|
|
1019
1386
|
};
|
|
1020
1387
|
}
|
|
1021
1388
|
|
|
@@ -1039,6 +1406,36 @@ const WorkspaceView = (() => {
|
|
|
1039
1406
|
};
|
|
1040
1407
|
}
|
|
1041
1408
|
|
|
1409
|
+
function buildMediaTab(entry, kind, url) {
|
|
1410
|
+
const pane = el("div", "wv-pane wv-pane--media wv-pane--" + kind);
|
|
1411
|
+
const media = document.createElement(kind);
|
|
1412
|
+
media.className = "wv-media-player wv-media-player--" + kind;
|
|
1413
|
+
media.controls = true;
|
|
1414
|
+
media.preload = "metadata";
|
|
1415
|
+
media.src = url;
|
|
1416
|
+
media.setAttribute("aria-label", entry.name);
|
|
1417
|
+
if (kind === "video") media.playsInline = true;
|
|
1418
|
+
|
|
1419
|
+
const error = el("div", "wv-media-error");
|
|
1420
|
+
error.textContent = t("workspace.previewUnsupported");
|
|
1421
|
+
media.addEventListener("error", () => pane.classList.add("is-error"));
|
|
1422
|
+
pane.append(media, error);
|
|
1423
|
+
|
|
1424
|
+
return {
|
|
1425
|
+
id: entry.path,
|
|
1426
|
+
entry,
|
|
1427
|
+
kind,
|
|
1428
|
+
el: pane,
|
|
1429
|
+
dirty: false,
|
|
1430
|
+
isMd: false,
|
|
1431
|
+
previewMode: false,
|
|
1432
|
+
previewEl: null,
|
|
1433
|
+
editor: null,
|
|
1434
|
+
imageUrl: null,
|
|
1435
|
+
mediaEl: media,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1042
1439
|
async function buildFallbackTab(entry) {
|
|
1043
1440
|
const pane = el("div", "wv-pane wv-pane--fallback");
|
|
1044
1441
|
|
|
@@ -1103,7 +1500,8 @@ const WorkspaceView = (() => {
|
|
|
1103
1500
|
};
|
|
1104
1501
|
}
|
|
1105
1502
|
|
|
1106
|
-
async function openTab(entry) {
|
|
1503
|
+
async function openTab(entry, opts) {
|
|
1504
|
+
const silent = !!(opts && opts.silent);
|
|
1107
1505
|
const existing = tabs.find((x) => x.id === entry.path);
|
|
1108
1506
|
if (existing) {
|
|
1109
1507
|
activate(existing.id);
|
|
@@ -1126,6 +1524,8 @@ const WorkspaceView = (() => {
|
|
|
1126
1524
|
tab = buildImageTab(entry, URL.createObjectURL(blob));
|
|
1127
1525
|
} else if (kind === "pdf") {
|
|
1128
1526
|
tab = buildPdfTab(entry, "/api/local-image?path=" + encodeURIComponent(Workspace.absPath(entry.path)));
|
|
1527
|
+
} else if (kind === "video" || kind === "audio") {
|
|
1528
|
+
tab = buildMediaTab(entry, kind, "/api/local-image?path=" + encodeURIComponent(Workspace.absPath(entry.path)));
|
|
1129
1529
|
} else if (kind === "binary") {
|
|
1130
1530
|
// The extension already says the browser cannot render this, so the
|
|
1131
1531
|
// OS application the user was going to pick is launched below without
|
|
@@ -1143,7 +1543,14 @@ const WorkspaceView = (() => {
|
|
|
1143
1543
|
}
|
|
1144
1544
|
} catch (err) {
|
|
1145
1545
|
console.error("preview failed:", err);
|
|
1146
|
-
|
|
1546
|
+
if (silent) {
|
|
1547
|
+
// Only a restore asks for silence, and its failures are files that no
|
|
1548
|
+
// longer open — rewriting the record keeps later reloads from
|
|
1549
|
+
// retrying them.
|
|
1550
|
+
persistTabs();
|
|
1551
|
+
} else {
|
|
1552
|
+
Modal.toast(t("workspace.previewFailed") + ": " + err.message, "error");
|
|
1553
|
+
}
|
|
1147
1554
|
return;
|
|
1148
1555
|
}
|
|
1149
1556
|
if (tabs.length === 0) ensureAsideWidth();
|
|
@@ -1185,15 +1592,12 @@ const WorkspaceView = (() => {
|
|
|
1185
1592
|
|
|
1186
1593
|
function destroy() {
|
|
1187
1594
|
restoreAsideWidth();
|
|
1188
|
-
tabs.forEach(
|
|
1189
|
-
if (tab.editor) tab.editor.destroy();
|
|
1190
|
-
if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
|
|
1191
|
-
});
|
|
1595
|
+
tabs.forEach(disposeTab);
|
|
1192
1596
|
tabs.length = 0;
|
|
1193
1597
|
activeId = null;
|
|
1194
1598
|
}
|
|
1195
1599
|
|
|
1196
|
-
return { root, openTab, destroy };
|
|
1600
|
+
return { root, openTab, activate, destroy };
|
|
1197
1601
|
}
|
|
1198
1602
|
|
|
1199
1603
|
// Draggable split between the file tree and the viewer. The tree width is a
|
|
@@ -1451,6 +1855,7 @@ const WorkspaceView = (() => {
|
|
|
1451
1855
|
}
|
|
1452
1856
|
|
|
1453
1857
|
reloadTree({ reset: true });
|
|
1858
|
+
restoreOpenTabs(viewer);
|
|
1454
1859
|
|
|
1455
1860
|
// The tree is otherwise loaded only once per mount. Reload it when the
|
|
1456
1861
|
// working directory changes (status-bar dir switch) and when the agent
|