openclacky 1.5.21 → 1.5.22

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -11,7 +11,8 @@
11
11
  // it shows for every session regardless of agent profile. All I/O goes through
12
12
  // WorkspaceStore.
13
13
  //
14
- // Depends on: WorkspaceStore, Clacky.ext, I18n, Modal, CodeEditor.
14
+ // Depends on: WorkspaceStore, Clacky.ext, Composer, I18n, Modal, CodeEditor,
15
+ // ImageZoom.
15
16
  // ───────────────────────────────────────────────────────────────────────────
16
17
  "use strict";
17
18
 
@@ -143,6 +144,30 @@ const WorkspaceView = (() => {
143
144
  '<path fill="#c3ccd4" d="M9.7 1.5v3.3H13z"/>'
144
145
  );
145
146
 
147
+ const ICON_TEXT = svgWrap(
148
+ '<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"/>' +
149
+ '<path fill="#c3ccd4" d="M9.7 1.5v3.3H13z"/>' +
150
+ '<path d="M4.8 7.5h5.4M4.8 9.9h5.4M4.8 12.3h3.4" stroke="#fff" stroke-width="1.1" stroke-linecap="round"/>'
151
+ );
152
+
153
+ const ICON_PDF = svgWrap(
154
+ '<rect x="1" y="1" width="14" height="14" rx="3" fill="#e5383b"/>' +
155
+ `<text x="8" y="10.4" text-anchor="middle" ${ICON_FONT} font-size="5.6" font-weight="700" letter-spacing="-0.2" fill="#fff">PDF</text>`
156
+ );
157
+
158
+ // Office-style tile: a lettered square overlapping a tinted page
159
+ function officeIcon(label, color) {
160
+ return svgWrap(
161
+ `<path fill="${color}" opacity="0.35" d="M5.5 1.5h6.2L14.5 4.3v9.7a.9.9 0 0 1-.9.9H5.5a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/>` +
162
+ `<rect x="1" y="4" width="9" height="9" rx="1.6" fill="${color}"/>` +
163
+ `<text x="5.5" y="11.1" text-anchor="middle" ${ICON_FONT} font-size="7" font-weight="800" fill="#fff">${label}</text>`
164
+ );
165
+ }
166
+
167
+ const ICON_WORD = officeIcon("W", "#2b579a");
168
+ const ICON_EXCEL = officeIcon("X", "#217346");
169
+ const ICON_PPT = officeIcon("P", "#d24726");
170
+
146
171
  const ICON_VIDEO = svgWrap(
147
172
  '<rect x="1.5" y="3" width="13" height="10" rx="2" fill="#5c8ee6"/>' +
148
173
  '<path d="m6.5 5.8 4 2.2-4 2.2z" fill="#fff"/>'
@@ -186,7 +211,11 @@ const WorkspaceView = (() => {
186
211
  gif: imgIcon("#a074c4"), webp: imgIcon("#a074c4"), ico: imgIcon("#a074c4"), bmp: imgIcon("#a074c4"),
187
212
  mp4: ICON_VIDEO, webm: ICON_VIDEO, mov: ICON_VIDEO,
188
213
  mp3: ICON_AUDIO, wav: ICON_AUDIO, ogg: ICON_AUDIO, aac: ICON_AUDIO, flac: ICON_AUDIO, m4a: ICON_AUDIO,
189
- pdf: boxIcon("P", "#e04b4b"),
214
+ pdf: ICON_PDF,
215
+ doc: ICON_WORD, docx: ICON_WORD, odt: ICON_WORD, rtf: ICON_WORD, wps: ICON_WORD,
216
+ xls: ICON_EXCEL, xlsx: ICON_EXCEL, ods: ICON_EXCEL, numbers: ICON_EXCEL,
217
+ ppt: ICON_PPT, pptx: ICON_PPT, odp: ICON_PPT, key: ICON_PPT,
218
+ txt: ICON_TEXT, log: ICON_TEXT,
190
219
  zip: boxIcon("Z", "#dba15d"), tar: boxIcon("Z", "#dba15d"), gz: boxIcon("Z", "#dba15d"),
191
220
  "7z": boxIcon("Z", "#dba15d"), rar: boxIcon("Z", "#dba15d"), dmg: boxIcon("Z", "#dba15d"), iso: boxIcon("Z", "#dba15d"),
192
221
  lock: ICON_LOCK,
@@ -312,6 +341,16 @@ const WorkspaceView = (() => {
312
341
  node.appendChild(children);
313
342
  row.addEventListener("click", () => toggleDir(entry, caret, children, icon, openFile));
314
343
  } else {
344
+ row.draggable = true;
345
+ row.addEventListener("dragstart", (e) => {
346
+ if (!e.dataTransfer || typeof Composer === "undefined" ||
347
+ typeof Composer.beginWorkspaceFileDrag !== "function") {
348
+ e.preventDefault();
349
+ return;
350
+ }
351
+ const started = Composer.beginWorkspaceFileDrag(e.dataTransfer, chatAttachment(entry));
352
+ if (!started) e.preventDefault();
353
+ });
315
354
  row.addEventListener("click", () => openFile(entry));
316
355
  }
317
356
 
@@ -505,6 +544,15 @@ const WorkspaceView = (() => {
505
544
  }
506
545
  }
507
546
 
547
+ function chatAttachment(entry) {
548
+ return {
549
+ type: "file",
550
+ name: entry.name,
551
+ path: Workspace.absPath(entry.path),
552
+ size: entry.size,
553
+ };
554
+ }
555
+
508
556
  // Hand the file to the composer, which stages it and focuses the input. The
509
557
  // absolute path is what the agent ultimately reads, so resolve it here where
510
558
  // the session's working directory is known.
@@ -513,11 +561,7 @@ const WorkspaceView = (() => {
513
561
  console.error("add to chat failed: Sessions.attachWorkspaceFile is unavailable");
514
562
  return;
515
563
  }
516
- Sessions.attachWorkspaceFile({
517
- name: entry.name,
518
- path: Workspace.absPath(entry.path),
519
- size: entry.size,
520
- });
564
+ Sessions.attachWorkspaceFile(chatAttachment(entry));
521
565
  }
522
566
 
523
567
  async function toggleDir(entry, caret, children, iconEl, openFile) {
@@ -542,7 +586,8 @@ const WorkspaceView = (() => {
542
586
  highlightActiveRow();
543
587
  } catch (err) {
544
588
  console.error("workspace load failed:", err);
545
- children.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
589
+ children.replaceChildren();
590
+ renderLoadError(children, err);
546
591
  }
547
592
  }
548
593
 
@@ -563,6 +608,52 @@ const WorkspaceView = (() => {
563
608
  }
564
609
  }
565
610
 
611
+ // A session outlives its working dir all the time (deleted temp clone,
612
+ // removed workspace, renamed folder). The API flags that case with a code;
613
+ // "Failed to load files" alone leaves the user with nothing to act on, so
614
+ // name the cause and offer the folder picker the status bar already owns.
615
+ function isDirGone(err) {
616
+ const code = err && err.code;
617
+ return code === "working_dir_missing" || code === "directory_missing";
618
+ }
619
+
620
+ function renderLoadError(container, err) {
621
+ if (!isDirGone(err)) {
622
+ container.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
623
+ return;
624
+ }
625
+ const workingDirGone = err.code === "working_dir_missing";
626
+ const card = el("div", "wt-error-card");
627
+ const title = el("div", "wt-error-card-title");
628
+ title.textContent = t(workingDirGone ? "workspace.dirMissingTitle" : "workspace.dirGoneTitle");
629
+ const desc = el("div", "wt-error-card-desc");
630
+ desc.textContent = t(workingDirGone ? "workspace.dirMissingDesc" : "workspace.dirGoneDesc");
631
+ card.appendChild(title);
632
+ card.appendChild(desc);
633
+
634
+ if (workingDirGone) {
635
+ const fullPath = Workspace.state.workingDir || "";
636
+ const pathEl = el("div", "wt-error-card-path");
637
+ // A full path in a 168px-wide pane hides the segment that differs from
638
+ // other checkouts, which is the one worth reading.
639
+ pathEl.textContent = fullPath.length > 44 ? `…${fullPath.slice(-43)}` : fullPath;
640
+ pathEl.title = fullPath;
641
+ card.appendChild(pathEl);
642
+
643
+ const action = el("button", "btn-card-grid-action wt-error-card-action");
644
+ action.type = "button";
645
+ action.textContent = t("workspace.dirMissingAction");
646
+ // Reuse the status-bar picker's click: it owns the dialog, the PATCH and
647
+ // the "this folder no longer exists" wording for a missing bound path.
648
+ action.addEventListener("click", () => {
649
+ const statusBarDir = document.getElementById("sib-dir");
650
+ if (statusBarDir) statusBarDir.click();
651
+ });
652
+ card.appendChild(action);
653
+ }
654
+ container.replaceChildren(card);
655
+ }
656
+
566
657
  async function loadRoot(tree, openFile, opts) {
567
658
  if (!tree || !Workspace.state.hasSession()) return;
568
659
  // A different working dir (session switch) shares no history with what is
@@ -578,7 +669,9 @@ const WorkspaceView = (() => {
578
669
  entries = await Workspace.fetchEntries("");
579
670
  } catch (err) {
580
671
  console.error("workspace load failed:", err);
581
- if (firstLoad) tree.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
672
+ // Rows that stay on screen after the dir disappeared claim files exist
673
+ // where they don't; a transient failure keeps the listing it already has.
674
+ if (firstLoad || isDirGone(err)) renderLoadError(tree, err);
582
675
  return false;
583
676
  }
584
677
  reconcileEntries(tree, entries, openFile);
@@ -680,12 +773,6 @@ const WorkspaceView = (() => {
680
773
 
681
774
  const VIEWER_ASIDE_WIDTH = 700;
682
775
 
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
776
  // Open files outlive a reload: the strip is rebuilt from a per-session record
690
777
  // in localStorage, so refreshing the page (or coming back to a session) lands
691
778
  // on the same files. Paths only — every tab body is fetched from disk again.
@@ -806,38 +893,45 @@ const WorkspaceView = (() => {
806
893
 
807
894
  const content = el("div", "wv-content");
808
895
 
809
- function actionBtn(icon, title, cls) {
896
+ // `key` is the i18n lookup so a language switch can rewrite the hint
897
+ // after the toolbar has already been built.
898
+ function actionBtn(icon, key, cls) {
810
899
  const btn = el("button", "wv-action" + (cls ? " " + cls : ""));
811
900
  btn.type = "button";
812
- btn.title = title;
813
- btn.setAttribute("aria-label", title);
901
+ btn.setAttribute("data-tooltip", t(key));
902
+ btn.setAttribute("data-i18n-tooltip", key);
903
+ btn.setAttribute("aria-label", t(key));
814
904
  btn.innerHTML = icon;
815
905
  return btn;
816
906
  }
817
907
 
818
- const toggleTreeBtn = actionBtn(ICON_PANEL_LEFT, t("workspace.toggleTree"));
908
+ const toggleTreeBtn = actionBtn(ICON_PANEL_LEFT, "workspace.toggleTree");
819
909
  toggleTreeBtn.classList.add("wv-toggle-tree", "active");
820
910
  toggleTreeBtn.addEventListener("click", () => {
821
911
  const hidden = onToggleTree ? onToggleTree() : false;
822
912
  toggleTreeBtn.classList.toggle("active", !hidden);
823
913
  });
824
914
 
825
- const mobileBackBtn = actionBtn(ICON_ARROW_LEFT, t("workspace.backToFiles"), "wv-mobile-back");
915
+ const mobileBackBtn = actionBtn(ICON_ARROW_LEFT, "workspace.backToFiles", "wv-mobile-back");
826
916
  mobileBackBtn.addEventListener("click", () => {
827
917
  if (onShowMobileTree) onShowMobileTree();
828
918
  });
829
919
 
830
- const previewBtn = actionBtn(ICON_EYE, t("workspace.preview"));
831
- const splitBtn = actionBtn(ICON_COLUMNS, t("workspace.splitPreview"));
832
- const copyBtn = actionBtn(ICON_COPY, t("workspace.copyContent"));
833
- const saveBtn = actionBtn(ICON_SAVE, t("modal.save"));
920
+ const previewBtn = actionBtn(ICON_EYE, "workspace.preview");
921
+ const splitBtn = actionBtn(ICON_COLUMNS, "workspace.splitPreview");
922
+ const copyBtn = actionBtn(ICON_COPY, "workspace.copyContent");
923
+ const saveBtn = actionBtn(ICON_SAVE, "modal.save");
834
924
 
835
925
  // Split button shown for binary (non-previewable) tabs: text part opens
836
926
  // with the OS default app, the chevron opens the "open with" dropdown.
837
927
  const openGroup = el("div", "wv-action-group");
838
928
  const openMainBtn = el("button", "wv-action wv-action--open");
839
929
  openMainBtn.type = "button";
840
- openMainBtn.title = t("workspace.openDefault");
930
+ const setOpenHint = (hint) => {
931
+ openMainBtn.setAttribute("data-tooltip", hint);
932
+ openMainBtn.setAttribute("aria-label", hint);
933
+ };
934
+ setOpenHint(t("workspace.openDefault"));
841
935
  const openIconEl = el("span", "wv-main-app-icon");
842
936
  openIconEl.innerHTML = ICON_EXTERNAL;
843
937
  const openLabel = el("span");
@@ -849,7 +943,7 @@ const WorkspaceView = (() => {
849
943
  if (!tab) return;
850
944
  await openWithSystem(tab.entry);
851
945
  });
852
- const openArrowBtn = actionBtn(ICON_CHEVRON_DOWN, t("workspace.openWith"));
946
+ const openArrowBtn = actionBtn(ICON_CHEVRON_DOWN, "workspace.openWithMenu");
853
947
  openArrowBtn.addEventListener("click", (e) => {
854
948
  e.stopPropagation();
855
949
  const tab = activeTab();
@@ -904,7 +998,8 @@ const WorkspaceView = (() => {
904
998
 
905
999
  const close = el("span", "wv-tab-close");
906
1000
  close.innerHTML = ICON_CLOSE;
907
- close.title = t("workspace.closeTab");
1001
+ close.setAttribute("data-tooltip", t("workspace.closeTab"));
1002
+ close.setAttribute("data-i18n-tooltip", "workspace.closeTab");
908
1003
 
909
1004
  btn.appendChild(label);
910
1005
  btn.appendChild(dot);
@@ -952,7 +1047,7 @@ const WorkspaceView = (() => {
952
1047
  if (tab && tab.id === _openIconTabId) return;
953
1048
  _openIconTabId = tab ? tab.id : null;
954
1049
  openIconEl.innerHTML = ICON_EXTERNAL;
955
- openMainBtn.title = t("workspace.openDefault");
1050
+ setOpenHint(t("workspace.openDefault"));
956
1051
  if (!tab) return;
957
1052
  Workspace.fetchDefaultApp(tab.entry).then((app) => {
958
1053
  if (!app || _openIconTabId !== tab.id) return;
@@ -963,7 +1058,7 @@ const WorkspaceView = (() => {
963
1058
  if (_openIconTabId === tab.id) openIconEl.innerHTML = ICON_EXTERNAL;
964
1059
  });
965
1060
  openIconEl.replaceChildren(img);
966
- openMainBtn.title = t("workspace.openWith", { app: app.name });
1061
+ setOpenHint(t("workspace.openWith", { app: app.name }));
967
1062
  }).catch(() => { /* keep generic icon */ });
968
1063
  }
969
1064
 
@@ -991,7 +1086,7 @@ const WorkspaceView = (() => {
991
1086
 
992
1087
  function disposeTab(tab) {
993
1088
  if (tab.editor) tab.editor.destroy();
994
- if (tab.zoomObserver) tab.zoomObserver.disconnect();
1089
+ if (tab.zoomController) tab.zoomController.destroy();
995
1090
  if (tab.imageUrl) URL.revokeObjectURL(tab.imageUrl);
996
1091
  if (tab.mediaEl) {
997
1092
  tab.mediaEl.pause();
@@ -1237,139 +1332,25 @@ const WorkspaceView = (() => {
1237
1332
  viewport.appendChild(img);
1238
1333
 
1239
1334
  const bar = el("div", "wv-zoom-bar");
1240
- const outBtn = actionBtn(ICON_ZOOM_OUT, t("workspace.zoomOut"), "wv-zoom-btn");
1335
+ const outBtn = actionBtn(ICON_ZOOM_OUT, "workspace.zoomOut", "wv-zoom-btn");
1241
1336
  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");
1337
+ const inBtn = actionBtn(ICON_ZOOM_IN, "workspace.zoomIn", "wv-zoom-btn");
1338
+ const fitBtn = actionBtn(ICON_ZOOM_FIT, "workspace.zoomFit", "wv-zoom-btn");
1244
1339
  bar.append(outBtn, level, inBtn, fitBtn);
1245
1340
  pane.append(viewport, bar);
1246
1341
 
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 + ")";
1342
+ const zoomController = ImageZoom.create({
1343
+ viewport,
1344
+ image: img,
1345
+ onChange(state) {
1346
+ level.textContent = Math.round(state.scale * 100) + "%";
1347
+ outBtn.disabled = !state.canZoomOut;
1348
+ inBtn.disabled = !state.canZoomIn;
1349
+ },
1353
1350
  });
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);
1351
+ inBtn.addEventListener("click", zoomController.zoomIn);
1352
+ outBtn.addEventListener("click", zoomController.zoomOut);
1353
+ fitBtn.addEventListener("click", zoomController.reset);
1373
1354
 
1374
1355
  return {
1375
1356
  id: entry.path,
@@ -1382,7 +1363,7 @@ const WorkspaceView = (() => {
1382
1363
  previewEl: null,
1383
1364
  editor: null,
1384
1365
  imageUrl: url,
1385
- zoomObserver: observer,
1366
+ zoomController,
1386
1367
  };
1387
1368
  }
1388
1369
 
@@ -1669,7 +1650,8 @@ const WorkspaceView = (() => {
1669
1650
  searchInput.setAttribute("aria-label", t("workspace.search"));
1670
1651
  const searchClear = el("button", "wt-search-clear");
1671
1652
  searchClear.type = "button";
1672
- searchClear.title = t("workspace.closeTab");
1653
+ searchClear.setAttribute("data-tooltip", t("workspace.closeTab"));
1654
+ searchClear.setAttribute("data-i18n-tooltip", "workspace.closeTab");
1673
1655
  searchClear.innerHTML = ICON_CLOSE;
1674
1656
  searchWrap.appendChild(searchIcon);
1675
1657
  searchWrap.appendChild(searchInput);
@@ -1677,7 +1659,8 @@ const WorkspaceView = (() => {
1677
1659
 
1678
1660
  const refresh = el("button", "wt-bar-btn");
1679
1661
  refresh.type = "button";
1680
- refresh.title = t("workspace.refresh");
1662
+ refresh.setAttribute("data-tooltip", t("workspace.refresh"));
1663
+ refresh.setAttribute("data-i18n-tooltip", "workspace.refresh");
1681
1664
  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>';
1682
1665
  bar.appendChild(searchWrap);
1683
1666
  bar.appendChild(refresh);
@@ -1923,8 +1906,15 @@ const WorkspaceView = (() => {
1923
1906
  return true;
1924
1907
  }
1925
1908
 
1926
- // autoOpenWithSystem is exported for the regression harness.
1927
- return { mount, openFile, autoOpenWithSystem };
1909
+ // autoOpenWithSystem is exported for the regression harness; the icon
1910
+ // helpers are reused by the git panel so both trees show the same file
1911
+ // glyphs.
1912
+ return {
1913
+ mount, openFile, autoOpenWithSystem,
1914
+ fileIcon: fileIconSvg,
1915
+ iconFolder: ICON_FOLDER,
1916
+ iconFolderOpen: ICON_FOLDER_OPEN,
1917
+ };
1928
1918
  })();
1929
1919
 
1930
1920
  if (window.Clacky) Clacky.WorkspaceView = WorkspaceView;