@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.
- package/CHANGELOG.md +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- 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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
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-${
|
|
205
|
-
${
|
|
206
|
-
? `<
|
|
207
|
-
: `<
|
|
208
|
-
<div class="msg-attachment-meta"><a href="#"
|
|
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
|
-
|
|
614
|
-
|
|
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
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
787
|
+
media.pause();
|
|
742
788
|
} catch {}
|
|
743
789
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
|
802
|
-
|
|
803
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
|
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 =
|
|
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
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
|
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
|
-
|
|
1304
|
-
modal.classList.add('visible');
|
|
1305
|
-
modal.setAttribute('aria-hidden', 'false');
|
|
1505
|
+
showFilePreviewModal();
|
|
1306
1506
|
renderFilePreview();
|
|
1307
1507
|
|
|
1308
|
-
|
|
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 ||
|
|
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
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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,
|
|
1555
|
+
await openDriveFilePreview(rawPath, openOptions);
|
|
1362
1556
|
return;
|
|
1363
1557
|
}
|
|
1364
|
-
await openWorkspaceFilePreview(rawPath,
|
|
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
|
-
|
|
1631
|
+
teardownMediaPreview();
|
|
1632
|
+
viewerTap.cancel();
|
|
1633
|
+
viewerControls.reset();
|
|
1370
1634
|
const modal = document.getElementById('file-preview-modal');
|
|
1371
|
-
modal.classList.
|
|
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} ${
|
|
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
|
-
|
|
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)}">${
|
|
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)}">${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2179
|
-
|
|
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
|
});
|