@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -14,6 +14,10 @@ import {
14
14
  getConversationCurrentWorkspaceRootPath,
15
15
  IS_SHARED_VIEW,
16
16
  conversations,
17
+ openSummaryModal,
18
+ closeSummaryModal,
19
+ renderSummaryModalContent,
20
+ setSummaryModalLoading,
17
21
  } from './store.js';
18
22
  import { openAnnotateEditor } from './annotate-editor.js';
19
23
  import {
@@ -25,7 +29,21 @@ import {
25
29
  loadDrivesRoots,
26
30
  loadDriveChildren,
27
31
  loadSessionRootTree,
32
+ createExplorerFolder,
33
+ renameExplorerEntry,
34
+ deleteExplorerEntry,
28
35
  } from './api-client.js';
36
+ import { MENU_SEPARATOR, bindLongPress, openContextMenu } from './context-menu.mjs';
37
+ import {
38
+ childPathOf,
39
+ descendantsOf,
40
+ dropPathsBelow,
41
+ entryNameOf,
42
+ isSameOrBelow,
43
+ parentPathOf,
44
+ rewritePathPrefix,
45
+ rewritePathSet,
46
+ } from './explorer-edit-state.mjs';
29
47
  import {
30
48
  extractPastedFiles,
31
49
  extractDroppedFiles,
@@ -49,9 +67,21 @@ import {
49
67
  copyReferenceTokenToClipboard,
50
68
  eventClosest,
51
69
  } from './router.js';
70
+ import { buildDownloadLink } from './file-version.mjs';
52
71
  import { markdownHeadingId, resolveFilePreviewLink } from './file-preview-navigation.mjs';
53
72
  import { openExternalNavigation } from './external-link-policy.mjs';
54
73
  import { attachCodeCopyButtons } from './code-copy.mjs';
74
+ import { createControlsState } from './viewer-controls-state.mjs';
75
+ import { createTapRecognizer } from './viewer-tap.mjs';
76
+ import { createSwipeRecognizer } from './viewer-gestures.mjs';
77
+ import { createViewerHistory } from './viewer-history.mjs';
78
+ import {
79
+ galleryAt,
80
+ galleryFromFolder,
81
+ galleryNeighborIndex,
82
+ galleryNeighbors,
83
+ galleryPositionLabel,
84
+ } from './viewer-gallery.mjs';
55
85
  import {
56
86
  deepestExistingAncestor,
57
87
  planRepoRehydration,
@@ -76,6 +106,15 @@ function isVideoMimeType(mimeType) {
76
106
  return String(mimeType || '').toLowerCase().startsWith('video/');
77
107
  }
78
108
 
109
+ function isAudioMimeType(mimeType) {
110
+ return String(mimeType || '').toLowerCase().startsWith('audio/');
111
+ }
112
+
113
+ // Kinds whose bytes arrive as text in the preview payload and read in a
114
+ // column; the others are shown edge to edge on black, or as a card.
115
+ const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
116
+ const DARK_PREVIEW_KINDS = new Set(['image', 'video', 'audio']);
117
+
79
118
  function normalizeVideoPreviewOptions(options = {}) {
80
119
  const startSeconds = Math.max(0, Number(options?.startSeconds ?? options?.startAtSeconds ?? 0) || 0);
81
120
  const preload = String(options?.preload || 'metadata').toLowerCase();
@@ -195,17 +234,18 @@ export function renderAttachmentMarkup(attachments, { messageId = '' } = {}) {
195
234
  : ''}
196
235
  </div>`
197
236
  : '';
198
- if ((isImage || isVideo) && rawUrl) {
199
- const jsName = escHtml(JSON.stringify(att?.name || 'attachment'));
200
- const jsUrl = escHtml(JSON.stringify(rawUrl));
201
- const jsType = escHtml(JSON.stringify(att?.type || 'image/jpeg'));
202
- const openHandler = `openUploadedAttachmentViewer(${jsName},${jsUrl},${jsType})`;
237
+ const isAudio = isAudioMimeType(att?.type);
238
+ if ((isImage || isVideo || isAudio) && rawUrl) {
239
+ // Opened by the conversation's click handler (see
240
+ // viewer-chat-gallery.mjs), which also finds the other media around it.
241
+ const mediaKind = isVideo ? 'video' : (isAudio ? 'audio' : 'image');
242
+ const chip = isVideo ? '🎞️' : '🎵';
203
243
  return `
204
- <div class="msg-attachment msg-attachment-${isVideo ? 'video' : 'image'}">
205
- ${isVideo
206
- ? `<div class="msg-attachment-video-chip">🎞️</div>`
207
- : `<img src="${escHtml(rawUrl)}" alt="${name}" loading="eager" decoding="async" onclick="${openHandler}">`}
208
- <div class="msg-attachment-meta"><a href="#" onclick="${openHandler};return false;">${name}</a> · ${type}${sizeText} · <a href="#" onclick="${openHandler};return false;">open</a>${continuityActions}</div>
244
+ <div class="msg-attachment msg-attachment-${mediaKind}" data-media-name="${name}" data-media-url="${escHtml(rawUrl)}" data-media-type="${escHtml(att?.type || 'image/jpeg')}">
245
+ ${isImage
246
+ ? `<img src="${escHtml(rawUrl)}" alt="${name}" loading="eager" decoding="async">`
247
+ : `<div class="msg-attachment-video-chip">${chip}</div>`}
248
+ <div class="msg-attachment-meta"><a href="#" data-media-open>${name}</a> · ${type}${sizeText} · <a href="#" data-media-open>open</a>${continuityActions}</div>
209
249
  </div>`;
210
250
  }
211
251
  if (rawUrl) {
@@ -610,8 +650,10 @@ function updateFilePreviewUiState() {
610
650
  const payload = filePreviewState.payload;
611
651
  const isMarkdown = payload?.kind === 'markdown';
612
652
  const isUpload = filePreviewState.source === 'upload';
613
- previewBtn.style.display = isUpload ? 'none' : '';
614
- rawBtn.style.display = isUpload ? 'none' : '';
653
+ // Preview/Raw only mean something for text; media has no raw form to show.
654
+ const hasTextModes = !isUpload && TEXT_PREVIEW_KINDS.has(payload?.kind);
655
+ previewBtn.style.display = hasTextModes ? '' : 'none';
656
+ rawBtn.style.display = hasTextModes ? '' : 'none';
615
657
  previewBtn.classList.toggle('active', filePreviewState.mode === 'preview');
616
658
  rawBtn.classList.toggle('active', filePreviewState.mode === 'raw');
617
659
  htmlBtn.style.display = (!isUpload && isMarkdown && filePreviewState.mode === 'preview') ? 'inline-block' : 'none';
@@ -625,6 +667,14 @@ function updateFilePreviewUiState() {
625
667
  if (annotateBtn) annotateBtn.hidden = !filePreviewSupportsAnnotation();
626
668
  const copyBtn = document.getElementById('file-preview-copy-btn');
627
669
  if (copyBtn) copyBtn.hidden = payload?.kind !== 'image';
670
+ const gallery = filePreviewState.gallery;
671
+ const bottomChrome = document.getElementById('file-preview-chrome-bottom');
672
+ if (bottomChrome) {
673
+ bottomChrome.hidden = !gallery;
674
+ document.getElementById('file-preview-position').textContent = galleryPositionLabel(gallery);
675
+ document.getElementById('file-preview-prev').disabled = galleryNeighborIndex(gallery, -1) === -1;
676
+ document.getElementById('file-preview-next').disabled = galleryNeighborIndex(gallery, 1) === -1;
677
+ }
628
678
  }
629
679
 
630
680
  /**
@@ -681,6 +731,7 @@ function snapshotFilePreviewState() {
681
731
  error: filePreviewState.error,
682
732
  payload: filePreviewState.payload,
683
733
  viewerOptions: filePreviewState.viewerOptions,
734
+ gallery: filePreviewState.gallery,
684
735
  scrollTop: Number(bodyEl?.scrollTop || 0),
685
736
  };
686
737
  }
@@ -728,25 +779,92 @@ function teardownImageZoom() {
728
779
  if (bodyEl) bodyEl.classList.remove('image-zoom-mode');
729
780
  }
730
781
 
731
- function teardownVideoPreview() {
732
- const video = videoPreview.videoEl;
733
- const onLoadedMetadata = videoPreview.onLoadedMetadata;
734
- const onError = videoPreview.onError;
735
- const onCanPlay = videoPreview.onCanPlay;
736
- if (video) {
737
- if (onLoadedMetadata) video.removeEventListener('loadedmetadata', onLoadedMetadata);
738
- if (onError) video.removeEventListener('error', onError);
739
- if (onCanPlay) video.removeEventListener('canplay', onCanPlay);
782
+ function teardownMediaPreview() {
783
+ const media = mediaPreview.el;
784
+ if (media) {
785
+ for (const [eventName, handler] of mediaPreview.listeners) media.removeEventListener(eventName, handler);
740
786
  try {
741
- video.pause();
787
+ media.pause();
742
788
  } catch {}
743
789
  }
744
- videoPreview.videoEl = null;
745
- videoPreview.onLoadedMetadata = null;
746
- videoPreview.onError = null;
747
- videoPreview.onCanPlay = null;
790
+ mediaPreview.el = null;
791
+ mediaPreview.listeners = [];
792
+ viewerControls.setPlaying(false);
748
793
  const bodyEl = document.getElementById('file-preview-body');
749
- if (bodyEl) bodyEl.classList.remove('video-preview-mode');
794
+ if (bodyEl) bodyEl.classList.remove('video-preview-mode', 'audio-preview-mode', 'card-preview-mode');
795
+ }
796
+
797
+ function listenMediaPreview(media, eventName, handler, options) {
798
+ media.addEventListener(eventName, handler, options);
799
+ mediaPreview.listeners.push([eventName, handler]);
800
+ }
801
+
802
+ // The hovering controls fade while a file plays; the player's own events
803
+ // drive that, so a pause from the native controls brings them back.
804
+ function followMediaPlayback(media) {
805
+ listenMediaPreview(media, 'play', () => viewerControls.setPlaying(true));
806
+ listenMediaPreview(media, 'pause', () => viewerControls.setPlaying(false));
807
+ listenMediaPreview(media, 'ended', () => viewerControls.setPlaying(false));
808
+ }
809
+
810
+ const viewerControls = createControlsState({
811
+ onChange: (visible) => {
812
+ document.getElementById('file-preview-modal')?.classList.toggle('controls-hidden', !visible);
813
+ },
814
+ });
815
+ const viewerTap = createTapRecognizer({
816
+ onTap: () => viewerControls.toggle(),
817
+ hasSelection: () => String(window.getSelection?.()?.toString() || '').length > 0,
818
+ });
819
+
820
+ /** The chrome's height goes into a CSS variable so text starts below it. */
821
+ function syncViewerChromeHeight() {
822
+ const modal = document.getElementById('file-preview-modal');
823
+ const chromeTop = document.getElementById('file-preview-chrome-top');
824
+ if (!modal || !chromeTop || !modal.classList.contains('visible')) return;
825
+ modal.style.setProperty('--file-viewer-chrome-top', `${Math.ceil(chromeTop.getBoundingClientRect().height)}px`);
826
+ }
827
+
828
+ function showFilePreviewModal() {
829
+ const modal = document.getElementById('file-preview-modal');
830
+ const wasVisible = modal.classList.contains('visible');
831
+ modal.classList.add('visible');
832
+ modal.setAttribute('aria-hidden', 'false');
833
+ if (!wasVisible) {
834
+ viewerControls.reset();
835
+ viewerTap.cancel();
836
+ viewerHistory.opened();
837
+ // The composer keeps the keyboard otherwise: keys would type into it
838
+ // under the viewer instead of moving through the gallery.
839
+ const active = document.activeElement;
840
+ if (active && active !== document.body && !modal.contains(active)) active.blur?.();
841
+ }
842
+ }
843
+
844
+ const viewerHistory = createViewerHistory({
845
+ history: window.history,
846
+ onBack: () => closeFilePreview(),
847
+ });
848
+ window.addEventListener('popstate', () => viewerHistory.onPopState());
849
+
850
+ function setViewerMediaMode(kind) {
851
+ document.getElementById('file-preview-modal')?.classList.toggle('media-mode', DARK_PREVIEW_KINDS.has(kind));
852
+ }
853
+
854
+ function fileCardMarkup({ icon, name, meta, actions }) {
855
+ return `
856
+ <div class="file-preview-card">
857
+ <div class="file-preview-card-icon">${icon}</div>
858
+ <div class="file-preview-card-name">${escHtml(name)}</div>
859
+ ${meta ? `<div class="file-preview-card-meta">${escHtml(meta)}</div>` : ''}
860
+ <div class="file-preview-card-actions">${actions}</div>
861
+ </div>`;
862
+ }
863
+
864
+ function downloadAnchorMarkup(download, plainName) {
865
+ const href = String(download?.href || '');
866
+ if (!href) return '';
867
+ return `<a class="file-preview-btn" href="${escHtml(href)}" download="${escHtml(download.downloadName || plainName)}">Download</a>`;
750
868
  }
751
869
 
752
870
  function setupImageZoom(container) {
@@ -798,11 +916,9 @@ let imgZoom = {
798
916
  baseW: 0,
799
917
  baseH: 0,
800
918
  };
801
- let videoPreview = {
802
- videoEl: null,
803
- onLoadedMetadata: null,
804
- onError: null,
805
- onCanPlay: null,
919
+ let mediaPreview = {
920
+ el: null,
921
+ listeners: [],
806
922
  };
807
923
  const IMG_ZOOM_MIN_FLOOR = 0.05;
808
924
  const IMG_ZOOM_MAX = 8;
@@ -812,6 +928,11 @@ function _isAtImgZoomMin() {
812
928
  return Math.abs(imgZoom.scale - imgZoom.minScale) <= IMG_ZOOM_EPS;
813
929
  }
814
930
 
931
+ /** True unless an image is zoomed in, when a sideways drag pans it instead. */
932
+ function imageZoomAtRest() {
933
+ return !imgZoom.container || _isAtImgZoomMin();
934
+ }
935
+
815
936
  /**
816
937
  * The image's laid-out size at scale 1, measured once with the zoom styles
817
938
  * removed. Deriving it from the live rect instead would be circular now that
@@ -1084,10 +1205,19 @@ function _imgZoomOnResize() {
1084
1205
  _recomputeImgZoomMinScale({ resetToMin: _isAtImgZoomMin() });
1085
1206
  }
1086
1207
 
1208
+ function uploadPreviewKind(mimeType) {
1209
+ const type = String(mimeType || '').toLowerCase();
1210
+ if (type.startsWith('image/')) return 'image';
1211
+ if (isVideoMimeType(type)) return 'video';
1212
+ if (isAudioMimeType(type)) return 'audio';
1213
+ if (type === 'application/pdf') return 'pdf';
1214
+ return 'binary';
1215
+ }
1216
+
1087
1217
  export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options = {}) {
1088
- const isImage = String(mimeType || '').startsWith('image/');
1089
- const isVideo = isVideoMimeType(mimeType);
1090
1218
  const viewerOptions = normalizeVideoPreviewOptions(options);
1219
+ filePreviewLoadSeq += 1;
1220
+ filePreviewHistory.length = 0;
1091
1221
  setFilePreviewState({
1092
1222
  path: String(name || 'attachment'),
1093
1223
  source: 'upload',
@@ -1096,18 +1226,34 @@ export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options
1096
1226
  loading: false,
1097
1227
  error: '',
1098
1228
  viewerOptions,
1229
+ gallery: options?.gallery || null,
1099
1230
  payload: {
1100
- kind: isImage ? 'image' : (isVideo ? 'video' : 'binary'),
1231
+ kind: uploadPreviewKind(mimeType),
1101
1232
  name: String(name || 'attachment'),
1102
1233
  rawUrl: String(contentUrl || ''),
1103
1234
  size: 0,
1104
1235
  contentType: String(mimeType || '').toLowerCase(),
1105
1236
  },
1106
1237
  });
1107
- const modal = document.getElementById('file-preview-modal');
1108
- modal.classList.add('visible');
1109
- modal.setAttribute('aria-hidden', 'false');
1238
+ showFilePreviewModal();
1110
1239
  renderFilePreview();
1240
+ prefetchGalleryNeighbors();
1241
+ }
1242
+
1243
+ /**
1244
+ * Open one item of a gallery: an uploaded attachment by its address, a
1245
+ * drive or workspace file by its path. The gallery rides along so the
1246
+ * viewer can move on from there.
1247
+ */
1248
+ export function openGalleryItem(item, gallery) {
1249
+ if (!item) return Promise.resolve(false);
1250
+ const source = item.source || gallery?.source || 'workspace';
1251
+ if (source === 'upload') {
1252
+ openUploadedAttachmentViewer(item.name, item.href, item.mime, { gallery });
1253
+ return Promise.resolve(true);
1254
+ }
1255
+ const open = source === 'workspace' ? openWorkspaceFilePreview : openDriveFilePreview;
1256
+ return open(item.path, { gallery, viaGallery: true }).then((opened) => Boolean(opened));
1111
1257
  }
1112
1258
 
1113
1259
  export function setFilePreviewMode(mode) {
@@ -1123,25 +1269,38 @@ export function toggleFilePreviewHtml() {
1123
1269
  renderFilePreview();
1124
1270
  }
1125
1271
 
1272
+ /** The address the browser fetches a previewed file's bytes from. */
1273
+ function previewRawHref(source, path, payload) {
1274
+ if (source === 'drives' || source === 'session') return driveFileHrefFromPath(path, payload?.version);
1275
+ if (source === 'upload') return String(payload?.rawUrl || '');
1276
+ return `${BASE}/api/files/${String(path || '').split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
1277
+ }
1278
+
1126
1279
  export function renderFilePreview() {
1127
1280
  teardownImageZoom();
1128
- teardownVideoPreview();
1281
+ teardownMediaPreview();
1282
+ viewerTap.cancel();
1283
+ setViewerMediaMode(filePreviewState.payload?.kind);
1284
+ requestAnimationFrame(syncViewerChromeHeight);
1129
1285
  const titleEl = document.getElementById('file-preview-title');
1130
1286
  const metaEl = document.getElementById('file-preview-meta');
1131
1287
  const bodyEl = document.getElementById('file-preview-body');
1132
1288
  const rawLink = document.getElementById('file-preview-open-raw');
1133
1289
  const payload = filePreviewState.payload;
1134
- const rawHref = filePreviewState.source === 'drives'
1135
- || filePreviewState.source === 'session'
1136
- ? driveFileHrefFromPath(filePreviewState.path)
1137
- : filePreviewState.source === 'upload'
1138
- ? String(payload?.rawUrl || '')
1139
- : `${BASE}/api/files/${filePreviewState.path.split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
1140
- rawLink.href = rawHref || '#';
1290
+ const rawHref = previewRawHref(filePreviewState.source, filePreviewState.path, payload);
1141
1291
  const fallbackName = String(filePreviewState.path || '').split('/').filter(Boolean).pop() || 'download';
1142
- rawLink.setAttribute('download', String(payload?.name || fallbackName));
1292
+ const plainName = String(payload?.name || fallbackName);
1293
+ // An upload is addressed by its content and never changes; a file on disk
1294
+ // does, so its link and the name it is saved under carry its version.
1295
+ const download = filePreviewState.source === 'upload'
1296
+ ? { href: rawHref, downloadName: plainName }
1297
+ : buildDownloadLink({ href: rawHref, name: plainName, version: payload?.version, mtimeMs: payload?.mtimeMs });
1298
+ rawLink.href = download.href || '#';
1299
+ rawLink.setAttribute('download', download.downloadName || plainName);
1143
1300
  rawLink.textContent = 'Download';
1144
- rawLink.setAttribute('title', 'Download file');
1301
+ rawLink.setAttribute('title', download.downloadName && download.downloadName !== plainName
1302
+ ? `Download as ${download.downloadName}`
1303
+ : 'Download file');
1145
1304
 
1146
1305
  const titlePath = filePreviewState.path || '';
1147
1306
  titleEl.textContent = titlePath || 'File preview';
@@ -1178,8 +1337,56 @@ export function renderFilePreview() {
1178
1337
  const truncatedLabel = payload.truncated ? ` · truncated to ${formatBytes(FILE_PREVIEW_MAX_BYTES)}` : '';
1179
1338
  metaEl.textContent = `${kindLabel}${langLabel} · ${formatBytes(payload.size || 0)}${truncatedLabel}`;
1180
1339
 
1340
+ const cardMeta = payload.size > 0 ? formatBytes(payload.size) : '';
1341
+
1181
1342
  if (payload.kind === 'binary') {
1182
- bodyEl.innerHTML = '<div class="file-preview-note">Binary file preview is not shown. Use <b>Download</b> to save the file.</div>';
1343
+ bodyEl.innerHTML = fileCardMarkup({
1344
+ icon: '📦',
1345
+ name: plainName,
1346
+ meta: cardMeta ? `${cardMeta} · no inline preview` : 'no inline preview',
1347
+ actions: downloadAnchorMarkup(download, plainName),
1348
+ });
1349
+ bodyEl.classList.add('card-preview-mode');
1350
+ return;
1351
+ }
1352
+
1353
+ if (payload.kind === 'pdf') {
1354
+ // The browser's own PDF viewer takes it in a new tab; the plain address
1355
+ // (no `as=` name) keeps the answer inline there.
1356
+ const inlineHref = String(rawHref || payload.rawUrl || '');
1357
+ bodyEl.innerHTML = fileCardMarkup({
1358
+ icon: '📄',
1359
+ name: plainName,
1360
+ meta: cardMeta ? `PDF · ${cardMeta}` : 'PDF',
1361
+ actions: `${inlineHref ? `<button type="button" class="file-preview-btn" data-viewer-open-tab="${escHtml(inlineHref)}">Open in new tab</button>` : ''}${downloadAnchorMarkup(download, plainName)}`,
1362
+ });
1363
+ bodyEl.classList.add('card-preview-mode');
1364
+ return;
1365
+ }
1366
+
1367
+ if (payload.kind === 'audio') {
1368
+ const audioHref = String(rawHref || payload.rawUrl || '');
1369
+ bodyEl.innerHTML = `
1370
+ <div class="file-preview-audio-shell">
1371
+ ${fileCardMarkup({
1372
+ icon: '🎵',
1373
+ name: plainName,
1374
+ meta: cardMeta,
1375
+ actions: audioHref ? `<audio class="file-preview-audio" controls preload="metadata" src="${escHtml(audioHref)}"></audio>` : '<span class="file-preview-note">Audio preview unavailable.</span>',
1376
+ })}
1377
+ </div>`;
1378
+ bodyEl.classList.add('audio-preview-mode');
1379
+ const audio = bodyEl.querySelector('audio');
1380
+ if (audio) {
1381
+ mediaPreview.el = audio;
1382
+ listenMediaPreview(audio, 'error', () => {
1383
+ const note = document.createElement('div');
1384
+ note.className = 'file-preview-note';
1385
+ note.innerHTML = 'Audio preview unavailable. Use <b>Download</b> to save the file.';
1386
+ audio.replaceWith(note);
1387
+ }, { once: true });
1388
+ followMediaPlayback(audio);
1389
+ }
1183
1390
  return;
1184
1391
  }
1185
1392
 
@@ -1197,13 +1404,6 @@ export function renderFilePreview() {
1197
1404
  return;
1198
1405
  }
1199
1406
 
1200
- if (filePreviewState.mode === 'raw') {
1201
- bodyEl.innerHTML = payload.kind === 'video'
1202
- ? '<div class="file-preview-note">Video files are binary. Use <b>Download</b> to save the file.</div>'
1203
- : `<div class="file-preview-code"><pre><code>${escHtml(String(payload.content || ''))}</code></pre></div>`;
1204
- return;
1205
- }
1206
-
1207
1407
  if (payload.kind === 'video') {
1208
1408
  const videoHref = String(rawHref || payload.rawUrl || '');
1209
1409
  const viewerOptions = filePreviewState.viewerOptions || {};
@@ -1214,35 +1414,30 @@ export function renderFilePreview() {
1214
1414
  bodyEl.innerHTML = `
1215
1415
  <div class="file-preview-video-shell" data-start-seconds="${escHtml(String(startSeconds))}" data-preload="${escHtml(preload)}">
1216
1416
  <video class="file-preview-video" controls playsinline preload="${escHtml(preload)}" src="${escHtml(videoHref)}"></video>
1217
- <div class="file-preview-note">${startSeconds > 0 ? `Will start at ${startSeconds.toFixed(2)}s.` : 'Video preview ready.'} ${preload === 'auto' ? 'Preloading enabled.' : 'Metadata preload enabled.'}</div>
1218
1417
  </div>`;
1219
1418
  bodyEl.classList.add('video-preview-mode');
1220
1419
  const shell = bodyEl.querySelector('.file-preview-video-shell');
1221
1420
  const video = shell?.querySelector('video');
1222
1421
  if (video) {
1223
- videoPreview.videoEl = video;
1224
- videoPreview.onLoadedMetadata = () => {
1422
+ mediaPreview.el = video;
1423
+ const tryPlay = () => {
1424
+ const playPromise = video.play?.();
1425
+ if (playPromise && typeof playPromise.catch === 'function') {
1426
+ playPromise.catch(() => {});
1427
+ }
1428
+ };
1429
+ listenMediaPreview(video, 'loadedmetadata', () => {
1225
1430
  if (startSeconds > 0 && Number.isFinite(video.duration) && video.duration > startSeconds) {
1226
1431
  try {
1227
1432
  video.currentTime = startSeconds;
1228
1433
  } catch {}
1229
1434
  }
1230
- if (autoplay) {
1231
- const playPromise = video.play?.();
1232
- if (playPromise && typeof playPromise.catch === 'function') {
1233
- playPromise.catch(() => {});
1234
- }
1235
- }
1236
- };
1237
- videoPreview.onCanPlay = () => {
1238
- if (autoplay && video.paused) {
1239
- const playPromise = video.play?.();
1240
- if (playPromise && typeof playPromise.catch === 'function') {
1241
- playPromise.catch(() => {});
1242
- }
1243
- }
1244
- };
1245
- videoPreview.onError = () => {
1435
+ if (autoplay) tryPlay();
1436
+ }, { once: true });
1437
+ listenMediaPreview(video, 'canplay', () => {
1438
+ if (autoplay && video.paused) tryPlay();
1439
+ });
1440
+ listenMediaPreview(video, 'error', () => {
1246
1441
  let errorNote = bodyEl.querySelector('.file-preview-video-error');
1247
1442
  if (!errorNote) {
1248
1443
  errorNote = document.createElement('div');
@@ -1250,19 +1445,22 @@ export function renderFilePreview() {
1250
1445
  bodyEl.appendChild(errorNote);
1251
1446
  }
1252
1447
  errorNote.innerHTML = 'Video preview unavailable. Use <b>Download</b> to save the file.';
1253
- };
1254
- video.addEventListener('loadedmetadata', videoPreview.onLoadedMetadata, { once: true });
1255
- video.addEventListener('canplay', videoPreview.onCanPlay);
1256
- video.addEventListener('error', videoPreview.onError, { once: true });
1448
+ }, { once: true });
1449
+ followMediaPlayback(video);
1257
1450
  }
1258
1451
  } else {
1259
1452
  bodyEl.innerHTML = '<div class="file-preview-note">Video preview unavailable.</div>';
1260
1453
  }
1261
1454
  return;
1262
1455
  }
1263
-
1456
+
1264
1457
  const rawText = String(payload.content || '');
1265
1458
 
1459
+ if (filePreviewState.mode === 'raw') {
1460
+ bodyEl.innerHTML = `<div class="file-preview-code"><pre><code>${escHtml(rawText)}</code></pre></div>`;
1461
+ return;
1462
+ }
1463
+
1266
1464
  if (payload.kind === 'markdown') {
1267
1465
  const html = renderMarkdownPreview(rawText, filePreviewState.allowHtml);
1268
1466
  bodyEl.innerHTML = `<article class="file-preview-markdown">${html}</article>`;
@@ -1285,92 +1483,173 @@ export function renderFilePreview() {
1285
1483
  attachCodeCopyButtons(bodyEl);
1286
1484
  }
1287
1485
 
1288
- export async function openWorkspaceFilePreview(rawPath, options = {}) {
1289
- const normalized = normalizeWorkspaceMentionPath(rawPath);
1290
- if (!normalized) return;
1486
+ // Two files asked for in a row (a quick swipe, say) must not race: only the
1487
+ // answer to the latest request is shown.
1488
+ let filePreviewLoadSeq = 0;
1489
+
1490
+ async function openFilePreviewFromSource(source, normalized, options, fetchPayload) {
1291
1491
  const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
1292
1492
  if (!options?.fromViewerLink) filePreviewHistory.length = 0;
1493
+ const seq = ++filePreviewLoadSeq;
1293
1494
  setFilePreviewState({
1294
1495
  path: normalized,
1295
- source: 'workspace',
1496
+ source,
1296
1497
  mode: 'preview',
1297
1498
  allowHtml: true,
1298
1499
  loading: true,
1299
1500
  error: '',
1300
1501
  payload: null,
1301
1502
  viewerOptions,
1503
+ gallery: options?.gallery || null,
1302
1504
  });
1303
- const modal = document.getElementById('file-preview-modal');
1304
- modal.classList.add('visible');
1305
- modal.setAttribute('aria-hidden', 'false');
1505
+ showFilePreviewModal();
1306
1506
  renderFilePreview();
1307
1507
 
1308
- const payload = await loadWorkspaceFilePreview(normalized, currentConversationId());
1508
+ // A step through the gallery takes the neighbour fetched ahead of time.
1509
+ const cached = options?.viaGallery ? galleryPayloadCache.get(galleryCacheKey(source, normalized)) : null;
1510
+ const payload = await (cached || fetchPayload());
1511
+ if (seq !== filePreviewLoadSeq) return false;
1309
1512
  if (!payload || payload.error) {
1310
1513
  filePreviewState.loading = false;
1311
- filePreviewState.error = payload?.error || 'Failed to load file preview';
1514
+ filePreviewState.error = payload?.error || `Failed to load ${source === 'workspace' ? '' : 'drive '}file preview`;
1312
1515
  renderFilePreview();
1313
1516
  return false;
1314
1517
  }
1518
+ rememberGalleryPayload(source, normalized, payload);
1315
1519
  filePreviewState.loading = false;
1316
1520
  filePreviewState.payload = payload;
1317
1521
  filePreviewState.path = String(payload.path || normalized);
1522
+ filePreviewState.source = source;
1318
1523
  filePreviewState.viewerOptions = viewerOptions;
1319
1524
  renderFilePreview();
1525
+ prefetchGalleryNeighbors();
1320
1526
  return true;
1321
1527
  }
1322
1528
 
1529
+ export async function openWorkspaceFilePreview(rawPath, options = {}) {
1530
+ const normalized = normalizeWorkspaceMentionPath(rawPath);
1531
+ if (!normalized) return;
1532
+ return openFilePreviewFromSource('workspace', normalized, options,
1533
+ () => loadWorkspaceFilePreview(normalized, currentConversationId()));
1534
+ }
1535
+
1323
1536
  export async function openDriveFilePreview(rawPath, options = {}) {
1324
1537
  const normalized = normalizeDriveBrowserPath(rawPath);
1325
1538
  if (!normalized) return;
1326
- const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
1327
- if (!options?.fromViewerLink) filePreviewHistory.length = 0;
1328
- setFilePreviewState({
1329
- path: normalized,
1330
- source: 'drives',
1331
- mode: 'preview',
1332
- allowHtml: true,
1333
- loading: true,
1334
- error: '',
1335
- payload: null,
1336
- viewerOptions,
1337
- });
1338
- const modal = document.getElementById('file-preview-modal');
1339
- modal.classList.add('visible');
1340
- modal.setAttribute('aria-hidden', 'false');
1341
- renderFilePreview();
1539
+ return openFilePreviewFromSource('drives', normalized, options, () => loadDriveFilePreview(normalized));
1540
+ }
1342
1541
 
1343
- const payload = await loadDriveFilePreview(normalized);
1344
- if (!payload || payload.error) {
1345
- filePreviewState.loading = false;
1346
- filePreviewState.error = payload?.error || 'Failed to load drive file preview';
1347
- renderFilePreview();
1348
- return false;
1349
- }
1350
- filePreviewState.loading = false;
1351
- filePreviewState.payload = payload;
1352
- filePreviewState.path = String(payload.path || normalized);
1353
- filePreviewState.source = 'drives';
1354
- filePreviewState.viewerOptions = viewerOptions;
1355
- renderFilePreview();
1356
- return true;
1542
+ /** The files of the browser's current folder, as a gallery open at `openedPath`. */
1543
+ function repoFolderGallery(openedPath) {
1544
+ const node = repoBrowserState.nodeMap.get(String(repoBrowserState.currentPath || '')) || null;
1545
+ if (!node || node.type !== 'dir' || !Array.isArray(node.children)) return null;
1546
+ return galleryFromFolder(node.children, openedPath, {
1547
+ source: repoBrowserState.activeRoot === 'workspace' ? 'workspace' : 'drives',
1548
+ });
1357
1549
  }
1358
1550
 
1359
1551
  export async function openWorkspaceFilePreviewFromRepo(rawPath, options = {}) {
1552
+ const gallery = repoFolderGallery(rawPath);
1553
+ const openOptions = gallery ? { ...options, gallery } : options;
1360
1554
  if (repoBrowserState.activeRoot !== 'workspace') {
1361
- await openDriveFilePreview(rawPath, options);
1555
+ await openDriveFilePreview(rawPath, openOptions);
1362
1556
  return;
1363
1557
  }
1364
- await openWorkspaceFilePreview(rawPath, options);
1558
+ await openWorkspaceFilePreview(rawPath, openOptions);
1559
+ }
1560
+
1561
+ /** Show the file `delta` steps along the gallery; false at either end. */
1562
+ export async function stepFilePreviewGallery(delta) {
1563
+ const gallery = filePreviewState.gallery;
1564
+ const index = galleryNeighborIndex(gallery, delta);
1565
+ if (index === -1) return false;
1566
+ const next = galleryAt(gallery, index);
1567
+ return openGalleryItem(next.items[index], next);
1568
+ }
1569
+
1570
+ // Preview payloads of the open file's neighbours, fetched ahead so a swipe
1571
+ // lands on the file and not on "Fetching…"; an image's bytes are asked for
1572
+ // too, under the very address the viewer will use. Entries are promises.
1573
+ const galleryPayloadCache = new Map();
1574
+ const GALLERY_PAYLOAD_CACHE_MAX = 12;
1575
+
1576
+ function galleryCacheKey(source, path) {
1577
+ return `${source}:${path}`;
1578
+ }
1579
+
1580
+ function rememberGalleryPayload(source, path, payload) {
1581
+ const key = galleryCacheKey(source, path);
1582
+ galleryPayloadCache.delete(key);
1583
+ galleryPayloadCache.set(key, Promise.resolve(payload));
1584
+ while (galleryPayloadCache.size > GALLERY_PAYLOAD_CACHE_MAX) {
1585
+ galleryPayloadCache.delete(galleryPayloadCache.keys().next().value);
1586
+ }
1587
+ }
1588
+
1589
+ function prefetchGalleryNeighbors() {
1590
+ const gallery = filePreviewState.gallery;
1591
+ if (!gallery) return;
1592
+ for (const item of galleryNeighbors(gallery)) {
1593
+ const source = item.source || gallery.source;
1594
+ if (source === 'upload') {
1595
+ // An attachment has no preview to fetch; its picture can be primed.
1596
+ if (String(item.mime || '').startsWith('image/') && item.href) {
1597
+ const image = new Image();
1598
+ image.src = item.href;
1599
+ }
1600
+ continue;
1601
+ }
1602
+ const normalized = source === 'workspace'
1603
+ ? normalizeWorkspaceMentionPath(item.path)
1604
+ : normalizeDriveBrowserPath(item.path);
1605
+ if (!normalized) continue;
1606
+ const key = galleryCacheKey(source, normalized);
1607
+ if (galleryPayloadCache.has(key)) continue;
1608
+ const request = (source === 'workspace'
1609
+ ? loadWorkspaceFilePreview(normalized, currentConversationId())
1610
+ : loadDriveFilePreview(normalized)
1611
+ ).then((payload) => {
1612
+ if (!payload || payload.error) {
1613
+ galleryPayloadCache.delete(key);
1614
+ return null;
1615
+ }
1616
+ if (payload.kind === 'image') {
1617
+ const image = new Image();
1618
+ image.src = previewRawHref(source, String(payload.path || normalized), payload);
1619
+ }
1620
+ return payload;
1621
+ }).catch(() => {
1622
+ galleryPayloadCache.delete(key);
1623
+ return null;
1624
+ });
1625
+ rememberGalleryPayload(source, normalized, request);
1626
+ }
1365
1627
  }
1366
1628
 
1367
1629
  export function closeFilePreview() {
1368
1630
  teardownImageZoom();
1369
- teardownVideoPreview();
1631
+ teardownMediaPreview();
1632
+ viewerTap.cancel();
1633
+ viewerControls.reset();
1370
1634
  const modal = document.getElementById('file-preview-modal');
1371
- modal.classList.remove('visible');
1635
+ const wasVisible = modal.classList.contains('visible');
1636
+ modal.classList.remove('visible', 'controls-hidden', 'media-mode');
1372
1637
  modal.setAttribute('aria-hidden', 'true');
1373
1638
  filePreviewHistory.length = 0;
1639
+ filePreviewState.gallery = null;
1640
+ galleryPayloadCache.clear();
1641
+ viewerSwipe.cancel();
1642
+ clearViewerDragStyles();
1643
+ if (wasVisible) viewerHistory.closed();
1644
+ }
1645
+
1646
+ function clearViewerDragStyles() {
1647
+ const bodyEl = document.getElementById('file-preview-body');
1648
+ const modal = document.getElementById('file-preview-modal');
1649
+ bodyEl.style.transform = '';
1650
+ bodyEl.style.transition = '';
1651
+ modal.style.opacity = '';
1652
+ modal.style.transition = '';
1374
1653
  }
1375
1654
 
1376
1655
  export function goBackFilePreview() {
@@ -1491,6 +1770,371 @@ function updateRepoToolbarUi() {
1491
1770
  heavyBtn.textContent = workspaceRoot
1492
1771
  ? `Heavy: ${repoBrowserState.workspaceIncludeHeavy ? 'On' : 'Off'}`
1493
1772
  : 'Heavy: n/a';
1773
+ const newFolderBtn = document.getElementById('repo-new-folder-btn');
1774
+ if (newFolderBtn) newFolderBtn.disabled = !canCreateRepoFolderIn(String(repoBrowserState.currentPath || ''));
1775
+ }
1776
+
1777
+ // ---------------------------------------------------------------------------
1778
+ // Explorer edits: new folder, rename, delete.
1779
+ //
1780
+ // The two inline editors live in module state, not in repoBrowserState: they
1781
+ // are transient, belong to one open explorer, and must not survive a root
1782
+ // switch or a close. Both are rendered by the normal render functions, which
1783
+ // capture the typed text and the focus before they replace the markup, so a
1784
+ // background re-render (a child load landing) does not eat a half-typed name.
1785
+ // ---------------------------------------------------------------------------
1786
+
1787
+ /** `{ parentPath, value, error, busy }` while the New-folder row is open. */
1788
+ let repoNewFolderDraft = null;
1789
+ /** `{ path, value, busy }` while a name is being edited in place. */
1790
+ let repoRenameDraft = null;
1791
+ /** `{ path, name, kind }` while the delete confirmation is open. */
1792
+ let repoDeletePending = null;
1793
+ /** Set while a render swaps markup, so the blur it causes is not a cancel. */
1794
+ let repoInlineRenderInProgress = false;
1795
+
1796
+ /** Rename and delete exist in the workspace and drives roots, outside the CWD picker. */
1797
+ function canEditRepoEntries() {
1798
+ return repoBrowserState.activeRoot !== 'session' && !repoBrowserCwdPickModeActive();
1799
+ }
1800
+
1801
+ /** A folder can be created wherever a real directory is open; the Windows "Drives" list is not one. */
1802
+ function canCreateRepoFolderIn(dirPath) {
1803
+ if (repoBrowserState.loading || repoBrowserState.error || !repoBrowserState.tree) return false;
1804
+ const node = repoBrowserState.nodeMap.get(String(dirPath || ''));
1805
+ if (!node || node.type !== 'dir') return false;
1806
+ if (repoBrowserState.activeRoot === 'drives' && !String(node.path || '')) return false;
1807
+ return true;
1808
+ }
1809
+
1810
+ function clearRepoInlineEdits() {
1811
+ repoNewFolderDraft = null;
1812
+ repoRenameDraft = null;
1813
+ }
1814
+
1815
+ export function startRepoNewFolder(parentPath = null) {
1816
+ const target = parentPath === null ? String(repoBrowserState.currentPath || '') : String(parentPath || '');
1817
+ if (!canCreateRepoFolderIn(target)) return;
1818
+ repoRenameDraft = null;
1819
+ repoNewFolderDraft = { parentPath: target, value: '', error: '', busy: false };
1820
+ renderRepoBrowser();
1821
+ focusRepoInlineInput('[data-repo-new-folder-input]');
1822
+ }
1823
+
1824
+ export function cancelRepoNewFolder() {
1825
+ if (!repoNewFolderDraft || repoNewFolderDraft.busy) return;
1826
+ repoNewFolderDraft = null;
1827
+ renderRepoFolder();
1828
+ }
1829
+
1830
+ export async function commitRepoNewFolder() {
1831
+ const draft = repoNewFolderDraft;
1832
+ if (!draft || draft.busy) return;
1833
+ const name = String(draft.value || '').trim();
1834
+ if (!name) {
1835
+ draft.error = 'Enter a name';
1836
+ renderRepoFolder();
1837
+ focusRepoInlineInput('[data-repo-new-folder-input]');
1838
+ return;
1839
+ }
1840
+ draft.busy = true;
1841
+ draft.error = '';
1842
+ renderRepoFolder();
1843
+ const root = repoBrowserState.activeRoot;
1844
+ try {
1845
+ const result = await createExplorerFolder({
1846
+ root,
1847
+ path: draft.parentPath,
1848
+ name,
1849
+ conversationId: currentConversationId(),
1850
+ });
1851
+ if (repoNewFolderDraft !== draft) return;
1852
+ repoNewFolderDraft = null;
1853
+ const newPath = String(result?.path || childPathOf(draft.parentPath, name));
1854
+ await reloadRepoDirectory(draft.parentPath);
1855
+ if (repoBrowserState.activeRoot === root && repoBrowserState.nodeMap.has(newPath)) {
1856
+ await setRepoCurrentPath(newPath);
1857
+ }
1858
+ showTransientRelayNotice(`Folder "${name}" created.`);
1859
+ } catch (error) {
1860
+ if (repoNewFolderDraft !== draft) return;
1861
+ draft.busy = false;
1862
+ draft.error = error?.message || 'Failed to create the folder';
1863
+ renderRepoFolder();
1864
+ focusRepoInlineInput('[data-repo-new-folder-input]');
1865
+ }
1866
+ }
1867
+
1868
+ export function startRepoRename(pathValue) {
1869
+ const entryPath = String(pathValue || '');
1870
+ const node = repoBrowserState.nodeMap.get(entryPath);
1871
+ if (!node || !entryPath || !canEditRepoEntries()) return;
1872
+ repoNewFolderDraft = null;
1873
+ repoRenameDraft = { path: entryPath, value: String(node.name || entryNameOf(entryPath)), busy: false };
1874
+ renderRepoBrowser();
1875
+ focusRepoInlineInput('[data-repo-rename-input]', { select: true });
1876
+ }
1877
+
1878
+ export function cancelRepoRename() {
1879
+ if (!repoRenameDraft || repoRenameDraft.busy) return;
1880
+ repoRenameDraft = null;
1881
+ renderRepoBrowser();
1882
+ }
1883
+
1884
+ export async function commitRepoRename() {
1885
+ const draft = repoRenameDraft;
1886
+ if (!draft || draft.busy) return;
1887
+ const node = repoBrowserState.nodeMap.get(draft.path);
1888
+ const oldName = String(node?.name || entryNameOf(draft.path));
1889
+ const newName = String(draft.value || '').trim();
1890
+ if (!node || !newName || newName === oldName) {
1891
+ cancelRepoRename();
1892
+ return;
1893
+ }
1894
+ draft.busy = true;
1895
+ renderRepoBrowser();
1896
+ const root = repoBrowserState.activeRoot;
1897
+ try {
1898
+ const result = await renameExplorerEntry({
1899
+ root,
1900
+ path: draft.path,
1901
+ newName,
1902
+ conversationId: currentConversationId(),
1903
+ });
1904
+ if (repoRenameDraft !== draft) return;
1905
+ repoRenameDraft = null;
1906
+ const parent = parentPathOf(draft.path);
1907
+ const newPath = String(result?.path || childPathOf(parent, newName));
1908
+ repoBrowserState.expandedPaths = rewritePathSet(repoBrowserState.expandedPaths, draft.path, newPath);
1909
+ repoBrowserState.collapsedPaths = rewritePathSet(repoBrowserState.collapsedPaths, draft.path, newPath);
1910
+ repoBrowserState.currentPath = rewritePathPrefix(repoBrowserState.currentPath, draft.path, newPath);
1911
+ await reloadRepoDirectory(parent);
1912
+ showTransientRelayNotice(`Renamed to "${newName}".`);
1913
+ } catch (error) {
1914
+ if (repoRenameDraft !== draft) return;
1915
+ draft.busy = false;
1916
+ renderRepoBrowser();
1917
+ focusRepoInlineInput('[data-repo-rename-input]', { select: true });
1918
+ showTransientRelayNotice(error?.message || 'Failed to rename', 6000);
1919
+ }
1920
+ }
1921
+
1922
+ function deleteConfirmationBody(pending, counts) {
1923
+ const name = escHtml(pending.name);
1924
+ const needsTypedName = counts.kind === 'dir' && (counts.files + counts.dirs) > 0;
1925
+ let lead;
1926
+ if (counts.kind !== 'dir') {
1927
+ lead = `<p>Delete the file <strong>${name}</strong>?</p>`;
1928
+ } else if (!needsTypedName) {
1929
+ lead = `<p>Delete the empty folder <strong>${name}</strong>?</p>`;
1930
+ } else {
1931
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
1932
+ const amount = counts.capped
1933
+ ? 'more than 10,000 entries'
1934
+ : `${plural(counts.files, 'file')} and ${plural(counts.dirs, 'folder')}`;
1935
+ lead = `<p>Delete the folder <strong>${name}</strong> and everything in it?</p>
1936
+ <p>It contains ${amount}.</p>
1937
+ <label class="repo-delete-confirm-label">Type the folder name to confirm
1938
+ <input type="text" class="repo-delete-confirm-input" data-repo-delete-confirm-input autocomplete="off" spellcheck="false" autocapitalize="off" placeholder="${name}">
1939
+ </label>`;
1940
+ }
1941
+ return `${lead}
1942
+ <div class="summary-modal-actions">
1943
+ <button class="chat-title-action-btn danger-btn" type="button" data-repo-delete-confirm${needsTypedName ? ' disabled data-keep-disabled="1"' : ''}>🗑 Delete</button>
1944
+ <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
1945
+ </div>`;
1946
+ }
1947
+
1948
+ export async function openRepoDeleteConfirmation(pathValue) {
1949
+ const entryPath = String(pathValue || '');
1950
+ const node = repoBrowserState.nodeMap.get(entryPath);
1951
+ if (!node || !entryPath || !canEditRepoEntries()) return;
1952
+ const pending = { path: entryPath, name: String(node.name || entryNameOf(entryPath)), kind: node.type === 'dir' ? 'dir' : 'file', root: repoBrowserState.activeRoot };
1953
+ repoDeletePending = pending;
1954
+ openSummaryModal({
1955
+ title: 'Delete',
1956
+ subtitle: entryPath,
1957
+ kind: 'explorer-delete',
1958
+ bodyHtml: '<p class="repo-empty">Counting entries…</p>',
1959
+ });
1960
+ let counts;
1961
+ try {
1962
+ counts = await deleteExplorerEntry({ root: pending.root, path: entryPath, conversationId: currentConversationId(), dryRun: true });
1963
+ } catch (error) {
1964
+ if (repoDeletePending !== pending) return;
1965
+ repoDeletePending = null;
1966
+ closeSummaryModal();
1967
+ showTransientRelayNotice(error?.message || 'Failed to read the entry', 6000);
1968
+ return;
1969
+ }
1970
+ if (repoDeletePending !== pending) return;
1971
+ renderSummaryModalContent({
1972
+ title: 'Delete',
1973
+ subtitle: entryPath,
1974
+ kind: 'explorer-delete',
1975
+ bodyHtml: deleteConfirmationBody(pending, {
1976
+ kind: String(counts?.kind || pending.kind),
1977
+ files: Number(counts?.files || 0),
1978
+ dirs: Number(counts?.dirs || 0),
1979
+ capped: counts?.capped === true,
1980
+ }),
1981
+ });
1982
+ const input = document.querySelector('#summary-modal [data-repo-delete-confirm-input]');
1983
+ if (input) input.focus();
1984
+ }
1985
+
1986
+ async function confirmRepoDelete() {
1987
+ const pending = repoDeletePending;
1988
+ if (!pending) return;
1989
+ const button = document.querySelector('#summary-modal [data-repo-delete-confirm]');
1990
+ if (button?.disabled) return;
1991
+ setSummaryModalLoading(true);
1992
+ try {
1993
+ await deleteExplorerEntry({ root: pending.root, path: pending.path, conversationId: currentConversationId() });
1994
+ } catch (error) {
1995
+ if (repoDeletePending !== pending) return;
1996
+ setSummaryModalLoading(false);
1997
+ showTransientRelayNotice(error?.message || 'Failed to delete', 6000);
1998
+ return;
1999
+ }
2000
+ if (repoDeletePending !== pending) return;
2001
+ repoDeletePending = null;
2002
+ closeSummaryModal();
2003
+ if (repoBrowserState.activeRoot !== pending.root) return;
2004
+ const parent = parentPathOf(pending.path);
2005
+ repoBrowserState.expandedPaths = dropPathsBelow(repoBrowserState.expandedPaths, pending.path);
2006
+ repoBrowserState.collapsedPaths = dropPathsBelow(repoBrowserState.collapsedPaths, pending.path);
2007
+ if (isSameOrBelow(repoBrowserState.currentPath, pending.path)) repoBrowserState.currentPath = parent;
2008
+ await reloadRepoDirectory(parent);
2009
+ showTransientRelayNotice(`Deleted "${pending.name}".`);
2010
+ }
2011
+
2012
+ /**
2013
+ * Refetch one folder's children after a change in it, then re-expand what was
2014
+ * open below it. The session root itself is reloaded whole: its listing is
2015
+ * special (a missing folder lists as empty, the transcript is a sibling) and
2016
+ * the plain children route would not do.
2017
+ */
2018
+ async function reloadRepoDirectory(dirPath) {
2019
+ const target = String(dirPath || '');
2020
+ const node = repoBrowserState.nodeMap.get(target);
2021
+ const sessionRootItself = repoBrowserState.activeRoot === 'session' && target === String(repoBrowserState.tree?.path || '');
2022
+ if (!node || node.type !== 'dir' || sessionRootItself) {
2023
+ refreshRepoBrowser();
2024
+ return;
2025
+ }
2026
+ const reopen = descendantsOf(repoBrowserState.expandedPaths, target);
2027
+ node.childrenLoaded = false;
2028
+ node.lazy = true;
2029
+ await ensureRepoChildrenLoaded(target);
2030
+ for (const descendant of reopen) {
2031
+ if (repoBrowserState.nodeMap.has(descendant)) await ensureRepoChildrenLoaded(descendant);
2032
+ }
2033
+ if (!repoBrowserState.nodeMap.has(String(repoBrowserState.currentPath || ''))) {
2034
+ repoBrowserState.currentPath = target;
2035
+ }
2036
+ renderRepoBrowser();
2037
+ }
2038
+
2039
+ function focusRepoInlineInput(selector, { select = false } = {}) {
2040
+ requestAnimationFrame(() => {
2041
+ const input = document.querySelector(`#repo-browser-modal ${selector}`);
2042
+ if (!input) return;
2043
+ input.focus();
2044
+ if (select) {
2045
+ // Select the stem only: the common rename keeps the extension.
2046
+ const value = String(input.value || '');
2047
+ const dot = value.lastIndexOf('.');
2048
+ input.setSelectionRange(0, dot > 0 ? dot : value.length);
2049
+ }
2050
+ });
2051
+ }
2052
+
2053
+ /** Before a render replaces the markup: keep the typed text and whether it had focus. */
2054
+ function captureRepoInlineEdits() {
2055
+ const newFolderInput = document.querySelector('#repo-folder [data-repo-new-folder-input]');
2056
+ if (newFolderInput && repoNewFolderDraft) {
2057
+ repoNewFolderDraft.value = newFolderInput.value;
2058
+ repoNewFolderDraft.focused = document.activeElement === newFolderInput;
2059
+ }
2060
+ const renameInput = document.querySelector('#repo-browser-modal [data-repo-rename-input]');
2061
+ if (renameInput && repoRenameDraft) {
2062
+ repoRenameDraft.value = renameInput.value;
2063
+ repoRenameDraft.focused = document.activeElement === renameInput;
2064
+ repoRenameDraft.selection = [renameInput.selectionStart, renameInput.selectionEnd];
2065
+ }
2066
+ }
2067
+
2068
+ /** After the render: put the focus back where it was. */
2069
+ function restoreRepoInlineEditFocus() {
2070
+ if (repoNewFolderDraft?.focused) {
2071
+ const input = document.querySelector('#repo-folder [data-repo-new-folder-input]');
2072
+ if (input && document.activeElement !== input) input.focus();
2073
+ }
2074
+ if (repoRenameDraft?.focused) {
2075
+ const input = document.querySelector('#repo-browser-modal [data-repo-rename-input]');
2076
+ if (input && document.activeElement !== input) {
2077
+ input.focus();
2078
+ const [start, end] = repoRenameDraft.selection || [];
2079
+ if (Number.isFinite(start) && Number.isFinite(end)) input.setSelectionRange(start, end);
2080
+ }
2081
+ }
2082
+ }
2083
+
2084
+ /** Run `swap` (which replaces markup) with the inline edits preserved across it. */
2085
+ function withRepoInlineEditsPreserved(swap) {
2086
+ captureRepoInlineEdits();
2087
+ repoInlineRenderInProgress = true;
2088
+ try {
2089
+ swap();
2090
+ } finally {
2091
+ repoInlineRenderInProgress = false;
2092
+ }
2093
+ restoreRepoInlineEditFocus();
2094
+ }
2095
+
2096
+ function renameInputHtml(draft) {
2097
+ return `<input type="text" class="repo-rename-input" data-repo-rename-input value="${escHtml(draft.value)}" autocomplete="off" spellcheck="false" autocapitalize="off" aria-label="New name"${draft.busy ? ' disabled' : ''}>`;
2098
+ }
2099
+
2100
+ function newFolderRowHtml(draft) {
2101
+ return `<form class="repo-inline-edit" data-repo-new-folder-form>
2102
+ <span class="repo-inline-edit-icon">📁</span>
2103
+ <input type="text" data-repo-new-folder-input value="${escHtml(draft.value)}" placeholder="New folder name" autocomplete="off" spellcheck="false" autocapitalize="off" aria-label="New folder name"${draft.busy ? ' disabled' : ''}>
2104
+ <button type="submit" class="repo-browser-btn"${draft.busy ? ' disabled' : ''}>${draft.busy ? 'Creating…' : 'Create'}</button>
2105
+ <button type="button" class="repo-browser-btn" data-repo-new-folder-cancel${draft.busy ? ' disabled' : ''}>Cancel</button>
2106
+ <div class="repo-inline-edit-error" aria-live="polite">${escHtml(draft.error || '')}</div>
2107
+ </form>`;
2108
+ }
2109
+
2110
+ function repoEntryMenuItems(entryPath, isDir) {
2111
+ const items = [];
2112
+ if (isDir && canCreateRepoFolderIn(entryPath)) {
2113
+ items.push({ id: 'new-folder', label: '📁 New folder here', onSelect: () => { void setRepoCurrentPath(entryPath).then(() => startRepoNewFolder(entryPath)); } });
2114
+ }
2115
+ // Roots are never renamed or deleted: the workspace root, `/`, the session
2116
+ // root, and a drive (`C:`), which on Windows is a top-level child of the list.
2117
+ const isRoot = !entryPath
2118
+ || entryPath === '/'
2119
+ || entryPath === String(repoBrowserState.tree?.path || '')
2120
+ || (repoBrowserState.activeRoot !== 'workspace' && !parentPathOf(entryPath));
2121
+ if (canEditRepoEntries() && !isRoot) {
2122
+ if (items.length) items.push(MENU_SEPARATOR);
2123
+ items.push({ id: 'rename', label: 'Rename', onSelect: () => startRepoRename(entryPath) });
2124
+ items.push({ id: 'delete', label: 'Delete', danger: true, onSelect: () => { void openRepoDeleteConfirmation(entryPath); } });
2125
+ }
2126
+ return items;
2127
+ }
2128
+
2129
+ function openRepoEntryMenu(element, x, y) {
2130
+ const dirPath = element.getAttribute('data-repo-open-dir');
2131
+ const filePath = element.getAttribute('data-repo-open-file');
2132
+ const entryPath = dirPath !== null ? dirPath : filePath;
2133
+ if (entryPath === null) return false;
2134
+ const items = repoEntryMenuItems(String(entryPath), dirPath !== null);
2135
+ if (!items.length) return false;
2136
+ openContextMenu(x, y, items);
2137
+ return true;
1494
2138
  }
1495
2139
 
1496
2140
  export function renderRepoTreeNode(node) {
@@ -1519,8 +2163,11 @@ export function renderRepoTreeNode(node) {
1519
2163
  : (lazyUnloaded
1520
2164
  ? '<div class="repo-empty">Expand to load entries…</div>'
1521
2165
  : children.map(renderRepoTreeNode).join(''));
2166
+ const label = repoRenameDraft && repoRenameDraft.path === nodePath && nodePath
2167
+ ? renameInputHtml(repoRenameDraft)
2168
+ : escHtml(node.name || '/');
1522
2169
  return `<details class="repo-tree-node" data-repo-dir-path="${escHtml(nodePath)}"${openAttr}>
1523
- <summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${escHtml(node.name || '/')}</summary>
2170
+ <summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${label}</summary>
1524
2171
  <div class="repo-tree-children">${childrenHtml}</div>
1525
2172
  </details>`;
1526
2173
  }
@@ -1634,8 +2281,13 @@ export function renderRepoFolder() {
1634
2281
  const children = Array.isArray(node.children)
1635
2282
  ? node.children.filter((child) => child?.type !== 'dir')
1636
2283
  : [];
2284
+ const draftHtml = repoNewFolderDraft && repoNewFolderDraft.parentPath === String(node.path || '')
2285
+ ? newFolderRowHtml(repoNewFolderDraft)
2286
+ : '';
1637
2287
  if (!children.length) {
1638
- folderHost.innerHTML = '<div class="repo-empty">This folder has no files.</div>';
2288
+ withRepoInlineEditsPreserved(() => {
2289
+ folderHost.innerHTML = `${draftHtml}<div class="repo-empty">This folder has no files.</div>`;
2290
+ });
1639
2291
  return;
1640
2292
  }
1641
2293
 
@@ -1645,6 +2297,8 @@ export function renderRepoFolder() {
1645
2297
  const childPath = String(child.path || '');
1646
2298
  const rawHref = repoRawHref(childPath);
1647
2299
  const icon = repoIcon(child);
2300
+ const renaming = !!repoRenameDraft && repoRenameDraft.path === childPath;
2301
+ const nameHtml = renaming ? renameInputHtml(repoRenameDraft) : escHtml(child.name || childPath);
1648
2302
  if (isGrid) {
1649
2303
  const ext = String(child.ext || '').replace('.', '');
1650
2304
  const isImage = String(child.previewKind || '').toLowerCase() === 'image' || REPO_IMAGE_EXTENSIONS.has(ext.toLowerCase());
@@ -1654,7 +2308,7 @@ export function renderRepoFolder() {
1654
2308
  const fileMeta = `${String(child.previewKind || 'file')} · ${formatBytes(child.size || 0)}`;
1655
2309
  return `<div class="repo-card repo-card-clickable" data-repo-open-file="${escHtml(childPath)}">
1656
2310
  <div class="repo-card-thumb">${thumb}</div>
1657
- <div class="repo-card-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
2311
+ <div class="repo-card-name" title="${escHtml(child.name || childPath)}">${nameHtml}</div>
1658
2312
  <div class="repo-entry-meta">${escHtml(fileMeta)}</div>
1659
2313
  </div>`;
1660
2314
  }
@@ -1664,7 +2318,7 @@ export function renderRepoFolder() {
1664
2318
  <div class="repo-entry-main">
1665
2319
  <span>${icon}</span>
1666
2320
  <div style="min-width:0">
1667
- <div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
2321
+ <div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${nameHtml}</div>
1668
2322
  <div class="repo-entry-meta">${escHtml(mainMeta)}</div>
1669
2323
  </div>
1670
2324
  </div>
@@ -1672,7 +2326,9 @@ export function renderRepoFolder() {
1672
2326
  }).join('');
1673
2327
 
1674
2328
  const savedScrollTop = folderHost.scrollTop;
1675
- folderHost.innerHTML = `<div class="${wrapperClass}">${rows}</div>`;
2329
+ withRepoInlineEditsPreserved(() => {
2330
+ folderHost.innerHTML = `${draftHtml}<div class="${wrapperClass}">${rows}</div>`;
2331
+ });
1676
2332
  folderHost.scrollTop = savedScrollTop;
1677
2333
  }
1678
2334
 
@@ -1695,7 +2351,9 @@ export function renderRepoTree() {
1695
2351
  // The rebuild resets the pane's scroll to 0; put it back so re-renders
1696
2352
  // (child loads, refreshes) don't rubber-band a scroll in progress.
1697
2353
  const savedScrollTop = treeHost.scrollTop;
1698
- treeHost.innerHTML = renderRepoTreeNode(root);
2354
+ withRepoInlineEditsPreserved(() => {
2355
+ treeHost.innerHTML = renderRepoTreeNode(root);
2356
+ });
1699
2357
  updateRepoTreeSelection();
1700
2358
  treeHost.scrollTop = savedScrollTop;
1701
2359
  syncRepoTreeToCurrentPath();
@@ -1920,6 +2578,7 @@ export function setRepoBrowserRoot(root) {
1920
2578
  // A root switch invalidates any parked restore: the saved path belongs to the
1921
2579
  // tree we are leaving. The Set wipes below are correct here for the same reason.
1922
2580
  pendingRepoBrowserRestore = null;
2581
+ clearRepoInlineEdits();
1923
2582
  setRepoBrowserState({
1924
2583
  tree: null,
1925
2584
  nodeMap: new Map(),
@@ -2069,6 +2728,7 @@ export function closeRepoBrowser() {
2069
2728
  modal.setAttribute('aria-hidden', 'true');
2070
2729
  repoBrowserState.open = false;
2071
2730
  repoBrowserCwdPickHandler = null;
2731
+ clearRepoInlineEdits();
2072
2732
  }
2073
2733
 
2074
2734
  export function refreshRepoBrowser() {
@@ -2166,6 +2826,8 @@ export async function setRepoCurrentPath(pathValue) {
2166
2826
  updateRepoTreeSelection();
2167
2827
  syncRepoTreeToCurrentPath();
2168
2828
  updateRepoCwdPickBar();
2829
+ // The New-folder button follows the open folder (a drive list has none).
2830
+ updateRepoToolbarUi();
2169
2831
  }
2170
2832
 
2171
2833
  document.addEventListener('click', (event) => {
@@ -2175,11 +2837,133 @@ document.addEventListener('click', (event) => {
2175
2837
  void openWorkspaceFilePreview(anchor.dataset.workspacePath || '');
2176
2838
  });
2177
2839
 
2178
- document.getElementById('file-preview-modal').addEventListener('click', (event) => {
2179
- if (event.target.id === 'file-preview-modal') closeFilePreview();
2840
+ // A tap on the content shows or hides the floating controls; the recognizer
2841
+ // leaves drags, pinches, double taps and the content's own buttons alone.
2842
+ // While something plays, a mouse moving over the viewer brings the controls
2843
+ // back as players do; over a still image a click is the only switch, or a
2844
+ // nudge of the mouse would undo every click that hid them.
2845
+ for (const eventName of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']) {
2846
+ document.getElementById('file-preview-body').addEventListener(eventName, (event) => {
2847
+ viewerTap.handle(event);
2848
+ viewerSwipe.handle(event);
2849
+ if (eventName === 'pointermove' && event.pointerType === 'mouse' && viewerControls.playing) {
2850
+ viewerControls.interaction();
2851
+ }
2852
+ });
2853
+ }
2854
+ for (const id of ['file-preview-chrome-top', 'file-preview-chrome-bottom']) {
2855
+ document.getElementById(id).addEventListener('pointerdown', () => viewerControls.interaction());
2856
+ }
2857
+ window.addEventListener('resize', syncViewerChromeHeight);
2858
+
2859
+ // Moving through the gallery: the buttons below, the arrow keys, and a
2860
+ // sideways swipe of a finger (a mouse drag selects text and stays a drag).
2861
+ document.getElementById('file-preview-prev').addEventListener('click', () => { void stepFilePreviewGallery(-1); });
2862
+ document.getElementById('file-preview-next').addEventListener('click', () => { void stepFilePreviewGallery(1); });
2863
+
2864
+ function isEditableTarget(target) {
2865
+ return Boolean(target?.closest?.('input, textarea, select, [contenteditable]'));
2866
+ }
2867
+
2868
+ document.addEventListener('keydown', (event) => {
2869
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
2870
+ const modal = document.getElementById('file-preview-modal');
2871
+ if (!modal.classList.contains('visible')) return;
2872
+ if (document.getElementById('image-annotate-modal')?.classList.contains('visible')) return;
2873
+ if ((isEditableTarget(event.target) && modal.contains(event.target)) || !filePreviewState.gallery) return;
2874
+ event.preventDefault();
2875
+ void stepFilePreviewGallery(event.key === 'ArrowRight' ? 1 : -1);
2876
+ });
2877
+
2878
+ /** Something between `target` and the viewer body scrolls sideways itself. */
2879
+ function insideHorizontalScroller(target) {
2880
+ const bodyEl = document.getElementById('file-preview-body');
2881
+ for (let el = target instanceof Element ? target : null; el && el !== bodyEl; el = el.parentElement) {
2882
+ if (el.scrollWidth > el.clientWidth + 1) {
2883
+ const overflowX = getComputedStyle(el).overflowX;
2884
+ if (overflowX === 'auto' || overflowX === 'scroll') return true;
2885
+ }
2886
+ }
2887
+ return false;
2888
+ }
2889
+
2890
+ // A swipe down dismisses what does not scroll (a picture, a player, a card):
2891
+ // the content follows the finger and the viewer fades, then goes if the
2892
+ // finger went far or fast enough. Text scrolls under the finger instead and
2893
+ // is closed with ×, Escape or the back gesture.
2894
+ const DISMISS_FADE_PX = 480;
2895
+ const DISMISS_ANIMATION_MS = 200;
2896
+ let viewerDismissing = false;
2897
+
2898
+ function viewerDismissableBySwipe() {
2899
+ return !viewerDismissing && !TEXT_PREVIEW_KINDS.has(filePreviewState.payload?.kind) && imageZoomAtRest();
2900
+ }
2901
+
2902
+ function dismissFilePreviewWithSlide() {
2903
+ const bodyEl = document.getElementById('file-preview-body');
2904
+ const modal = document.getElementById('file-preview-modal');
2905
+ viewerDismissing = true;
2906
+ bodyEl.style.transition = `transform ${DISMISS_ANIMATION_MS}ms ease-in`;
2907
+ modal.style.transition = `opacity ${DISMISS_ANIMATION_MS}ms ease-in`;
2908
+ bodyEl.style.transform = 'translateY(100vh)';
2909
+ modal.style.opacity = '0';
2910
+ setTimeout(() => {
2911
+ viewerDismissing = false;
2912
+ closeFilePreview();
2913
+ }, DISMISS_ANIMATION_MS);
2914
+ }
2915
+
2916
+ const viewerSwipe = createSwipeRecognizer({
2917
+ canStart: (event) => event.pointerType !== 'mouse'
2918
+ && !viewerDismissing
2919
+ && !event.target?.closest?.('video, audio, input, textarea, select, [contenteditable]')
2920
+ && !insideHorizontalScroller(event.target),
2921
+ horizontalEnabled: () => Boolean(filePreviewState.gallery) && imageZoomAtRest(),
2922
+ verticalEnabled: viewerDismissableBySwipe,
2923
+ onStart: () => {
2924
+ const bodyEl = document.getElementById('file-preview-body');
2925
+ bodyEl.style.transition = 'none';
2926
+ document.getElementById('file-preview-modal').style.transition = 'none';
2927
+ },
2928
+ onMove: ({ axis, dx, dy }) => {
2929
+ const bodyEl = document.getElementById('file-preview-body');
2930
+ if (axis === 'y') {
2931
+ // Upwards there is nothing: the content barely gives.
2932
+ const shift = dy < 0 ? dy * 0.2 : dy;
2933
+ bodyEl.style.transform = `translateY(${shift}px)`;
2934
+ document.getElementById('file-preview-modal').style.opacity = String(Math.max(0.3, 1 - Math.max(0, dy) / DISMISS_FADE_PX));
2935
+ return;
2936
+ }
2937
+ // Past either end the content gives a little, then holds.
2938
+ const toward = galleryNeighborIndex(filePreviewState.gallery, dx < 0 ? 1 : -1);
2939
+ const shift = toward === -1 ? dx * 0.25 : dx;
2940
+ bodyEl.style.transform = `translateX(${shift}px)`;
2941
+ },
2942
+ onEnd: ({ axis, swipe }) => {
2943
+ if (axis === 'y' && swipe === 'down') {
2944
+ dismissFilePreviewWithSlide();
2945
+ return;
2946
+ }
2947
+ const bodyEl = document.getElementById('file-preview-body');
2948
+ const modal = document.getElementById('file-preview-modal');
2949
+ bodyEl.style.transition = 'transform 180ms ease';
2950
+ modal.style.transition = 'opacity 180ms ease';
2951
+ bodyEl.style.transform = '';
2952
+ modal.style.opacity = '';
2953
+ if (swipe === 'left') void stepFilePreviewGallery(1);
2954
+ else if (swipe === 'right') void stepFilePreviewGallery(-1);
2955
+ },
2180
2956
  });
2181
2957
 
2182
2958
  document.getElementById('file-preview-body').addEventListener('click', (event) => {
2959
+ const openTab = eventClosest(event, '[data-viewer-open-tab]');
2960
+ if (openTab) {
2961
+ event.preventDefault();
2962
+ openExternalNavigation(openTab.getAttribute('data-viewer-open-tab') || '', (url) => {
2963
+ window.dispatchEvent(new CustomEvent('copilot:external-link-fallback', { detail: { url } }));
2964
+ });
2965
+ return;
2966
+ }
2183
2967
  const anchor = eventClosest(event, '.file-preview-markdown a[href]');
2184
2968
  if (!anchor) return;
2185
2969
  const target = resolveFilePreviewLink(anchor.getAttribute('href'), filePreviewState.path);
@@ -2213,7 +2997,105 @@ document.getElementById('repo-browser-modal').addEventListener('click', (event)
2213
2997
  if (event.target.id === 'repo-browser-modal') closeRepoBrowser();
2214
2998
  });
2215
2999
 
3000
+ // The inline editors. Keys inside an input never reach the <summary> (Enter
3001
+ // would toggle the folder) or the document (Escape would close the explorer).
3002
+ function repoInlineInputOf(event) {
3003
+ return eventClosest(event, '[data-repo-rename-input], [data-repo-new-folder-input]');
3004
+ }
3005
+
3006
+ document.getElementById('repo-browser-modal').addEventListener('keydown', (event) => {
3007
+ const input = repoInlineInputOf(event);
3008
+ if (!input) return;
3009
+ const isRename = input.hasAttribute('data-repo-rename-input');
3010
+ if (event.key === 'Enter') {
3011
+ event.preventDefault();
3012
+ event.stopPropagation();
3013
+ if (isRename) void commitRepoRename();
3014
+ else void commitRepoNewFolder();
3015
+ } else if (event.key === 'Escape') {
3016
+ event.preventDefault();
3017
+ event.stopPropagation();
3018
+ if (isRename) cancelRepoRename();
3019
+ else cancelRepoNewFolder();
3020
+ } else if (event.key === ' ' && isRename) {
3021
+ // A space in a <summary> toggles it; in a name it is a space.
3022
+ event.stopPropagation();
3023
+ }
3024
+ });
3025
+
3026
+ document.getElementById('repo-browser-modal').addEventListener('input', (event) => {
3027
+ const input = repoInlineInputOf(event);
3028
+ if (!input) return;
3029
+ if (input.hasAttribute('data-repo-rename-input') && repoRenameDraft) repoRenameDraft.value = input.value;
3030
+ if (input.hasAttribute('data-repo-new-folder-input') && repoNewFolderDraft) repoNewFolderDraft.value = input.value;
3031
+ });
3032
+
3033
+ // Leaving an untouched rename input ends the edit; a changed name stays open
3034
+ // until Enter, so a stray tap cannot lose or commit it. Renders swap the
3035
+ // input out and back, and that blur is not a cancel.
3036
+ document.getElementById('repo-browser-modal').addEventListener('focusout', (event) => {
3037
+ if (repoInlineRenderInProgress) return;
3038
+ const input = eventClosest(event, '[data-repo-rename-input]');
3039
+ if (!input || !repoRenameDraft || repoRenameDraft.busy) return;
3040
+ const node = repoBrowserState.nodeMap.get(repoRenameDraft.path);
3041
+ const original = String(node?.name || entryNameOf(repoRenameDraft.path));
3042
+ if (String(input.value || '').trim() === original) cancelRepoRename();
3043
+ });
3044
+
3045
+ document.getElementById('repo-folder').addEventListener('submit', (event) => {
3046
+ if (!eventClosest(event, '[data-repo-new-folder-form]')) return;
3047
+ event.preventDefault();
3048
+ void commitRepoNewFolder();
3049
+ });
3050
+
3051
+ // Right-click on a desktop, long press on a phone: the entry's menu.
3052
+ for (const hostId of ['repo-tree', 'repo-folder']) {
3053
+ const host = document.getElementById(hostId);
3054
+ const selector = hostId === 'repo-tree' ? '[data-repo-open-dir]' : '[data-repo-open-file]';
3055
+ host.addEventListener('contextmenu', (event) => {
3056
+ if (repoInlineInputOf(event)) return;
3057
+ const element = eventClosest(event, selector);
3058
+ if (!element) return;
3059
+ if (openRepoEntryMenu(element, event.clientX, event.clientY)) event.preventDefault();
3060
+ });
3061
+ bindLongPress(host, selector, (element, x, y) => {
3062
+ if (element.querySelector('[data-repo-rename-input]')) return;
3063
+ openRepoEntryMenu(element, x, y);
3064
+ });
3065
+ }
3066
+
3067
+ // The delete confirmation lives in the shared summary modal.
3068
+ document.getElementById('summary-modal').addEventListener('click', (event) => {
3069
+ if (!eventClosest(event, '[data-repo-delete-confirm]')) return;
3070
+ event.preventDefault();
3071
+ void confirmRepoDelete();
3072
+ });
3073
+
3074
+ document.getElementById('summary-modal').addEventListener('input', (event) => {
3075
+ const input = eventClosest(event, '[data-repo-delete-confirm-input]');
3076
+ if (!input || !repoDeletePending) return;
3077
+ const button = document.querySelector('#summary-modal [data-repo-delete-confirm]');
3078
+ if (!button) return;
3079
+ // The modal's loading toggle re-enables every button it does not see as
3080
+ // kept disabled; the name gate has to be expressed through that mark.
3081
+ const mismatch = String(input.value || '').trim() !== repoDeletePending.name;
3082
+ button.disabled = mismatch;
3083
+ if (mismatch) button.dataset.keepDisabled = '1';
3084
+ else delete button.dataset.keepDisabled;
3085
+ });
3086
+
3087
+ document.getElementById('summary-modal').addEventListener('keydown', (event) => {
3088
+ if (event.key !== 'Enter' || !eventClosest(event, '[data-repo-delete-confirm-input]')) return;
3089
+ event.preventDefault();
3090
+ void confirmRepoDelete();
3091
+ });
3092
+
2216
3093
  document.getElementById('repo-tree').addEventListener('click', (event) => {
3094
+ if (repoInlineInputOf(event)) {
3095
+ // A click in the rename input must not toggle the folder it sits in.
3096
+ event.preventDefault();
3097
+ return;
3098
+ }
2217
3099
  const fileButton = eventClosest(event, '[data-repo-open-file]');
2218
3100
  if (fileButton) {
2219
3101
  event.preventDefault();
@@ -2258,6 +3140,12 @@ document.getElementById('repo-tree').addEventListener('click', (event) => {
2258
3140
  });
2259
3141
 
2260
3142
  document.getElementById('repo-folder').addEventListener('click', (event) => {
3143
+ if (eventClosest(event, '[data-repo-new-folder-cancel]')) {
3144
+ event.preventDefault();
3145
+ cancelRepoNewFolder();
3146
+ return;
3147
+ }
3148
+ if (eventClosest(event, '[data-repo-new-folder-form]') || repoInlineInputOf(event)) return;
2261
3149
  const navDir = eventClosest(event, '[data-repo-nav-dir]');
2262
3150
  if (navDir) {
2263
3151
  event.preventDefault();
@@ -2294,7 +3182,12 @@ document.addEventListener('keydown', (event) => {
2294
3182
  const modal = document.getElementById('file-preview-modal');
2295
3183
  const repoModal = document.getElementById('repo-browser-modal');
2296
3184
  const summaryModal = document.getElementById('summary-modal');
3185
+ // Topmost first. The summary modal sits above the explorer (a delete
3186
+ // confirmation opened from it) except in CWD-pick mode, where the explorer
3187
+ // is raised above the new-chat/summary modal that opened it.
3188
+ const summaryOnTop = summaryModal.classList.contains('visible') && !repoBrowserCwdPickModeActive();
2297
3189
  if (modal.classList.contains('visible')) closeFilePreview();
3190
+ else if (summaryOnTop) window.closeSummaryModal?.();
2298
3191
  else if (repoModal.classList.contains('visible')) closeRepoBrowser();
2299
3192
  else if (summaryModal.classList.contains('visible')) window.closeSummaryModal?.();
2300
3193
  });