@oar-sh/oar 0.9.0
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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,2112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BASE,
|
|
3
|
+
escHtml,
|
|
4
|
+
formatBytes,
|
|
5
|
+
FILE_PREVIEW_MAX_BYTES,
|
|
6
|
+
MAX_UPLOAD_ATTACHMENTS,
|
|
7
|
+
REPO_IMAGE_EXTENSIONS,
|
|
8
|
+
currentConvId,
|
|
9
|
+
selectedAttachments,
|
|
10
|
+
filePreviewState,
|
|
11
|
+
repoBrowserState,
|
|
12
|
+
workspaceRootPath,
|
|
13
|
+
showTransientRelayNotice,
|
|
14
|
+
getConversationCurrentWorkspaceRootPath,
|
|
15
|
+
} from './store.js';
|
|
16
|
+
import {
|
|
17
|
+
uploadAttachment,
|
|
18
|
+
loadWorkspaceFilePreview,
|
|
19
|
+
loadDriveFilePreview,
|
|
20
|
+
loadRepoTree,
|
|
21
|
+
loadRepoChildren,
|
|
22
|
+
loadDrivesRoots,
|
|
23
|
+
loadDriveChildren,
|
|
24
|
+
loadSessionRootTree,
|
|
25
|
+
} from './api-client.js';
|
|
26
|
+
import {
|
|
27
|
+
extractPastedFiles,
|
|
28
|
+
extractDroppedFiles,
|
|
29
|
+
isGenericClipboardName,
|
|
30
|
+
pastedFileName,
|
|
31
|
+
planAttachmentMerge,
|
|
32
|
+
overCapNoticeText,
|
|
33
|
+
} from './composer-paste.mjs';
|
|
34
|
+
import { reencodeReason, downscaleImageFile, browserDownscaleDeps } from './image-downscale.mjs';
|
|
35
|
+
import { imageAttachmentWarningText } from './model-vision-support.mjs';
|
|
36
|
+
import {
|
|
37
|
+
normalizeWorkspaceMentionPath,
|
|
38
|
+
normalizeDriveBrowserPath,
|
|
39
|
+
driveFileHrefFromPath,
|
|
40
|
+
workspacePreviewApiPath,
|
|
41
|
+
drivePreviewApiPath,
|
|
42
|
+
renderMarkdownPreview,
|
|
43
|
+
rewriteLocalAssetUrlsInNode,
|
|
44
|
+
buildReferenceToken,
|
|
45
|
+
copyTextToClipboard,
|
|
46
|
+
copyReferenceTokenToClipboard,
|
|
47
|
+
eventClosest,
|
|
48
|
+
} from './router.js';
|
|
49
|
+
import { markdownHeadingId, resolveFilePreviewLink } from './file-preview-navigation.mjs';
|
|
50
|
+
import { openExternalNavigation } from './external-link-policy.mjs';
|
|
51
|
+
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
52
|
+
import {
|
|
53
|
+
deepestExistingAncestor,
|
|
54
|
+
planRepoRehydration,
|
|
55
|
+
repoAncestorPaths,
|
|
56
|
+
} from './repo-browser-tree-state.mjs';
|
|
57
|
+
import {
|
|
58
|
+
writeRepoBrowserHeavyPreference,
|
|
59
|
+
writeRepoBrowserHiddenPreference,
|
|
60
|
+
} from './repo-browser-preferences.mjs';
|
|
61
|
+
import { joinLaunchCwdPath } from './launch-cwd-path.mjs';
|
|
62
|
+
|
|
63
|
+
function currentConversationId() {
|
|
64
|
+
return String(currentConvId || '').trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isVideoMimeType(mimeType) {
|
|
68
|
+
return String(mimeType || '').toLowerCase().startsWith('video/');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeVideoPreviewOptions(options = {}) {
|
|
72
|
+
const startSeconds = Math.max(0, Number(options?.startSeconds ?? options?.startAtSeconds ?? 0) || 0);
|
|
73
|
+
const preload = String(options?.preload || 'metadata').toLowerCase();
|
|
74
|
+
return {
|
|
75
|
+
startSeconds,
|
|
76
|
+
preload: preload === 'auto' ? 'auto' : 'metadata',
|
|
77
|
+
autoplay: options?.autoplay === true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function currentWorkspaceRootPathForSelection() {
|
|
82
|
+
return String(getConversationCurrentWorkspaceRootPath(currentConversationId()) || workspaceRootPath || '').trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function currentWorkspaceScopeSuffix() {
|
|
86
|
+
const convId = currentConversationId();
|
|
87
|
+
return convId ? `?conversationId=${encodeURIComponent(convId)}` : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setFilePreviewState(next) {
|
|
91
|
+
Object.assign(filePreviewState, next);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function setRepoBrowserState(next) {
|
|
95
|
+
Object.assign(repoBrowserState, next);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let repoBrowserReloadQueued = false;
|
|
99
|
+
// A refresh (hidden/heavy toggle, Refresh button) refetches a lazy tree, so the
|
|
100
|
+
// expansion it wants to restore has to be re-applied once the new root lands.
|
|
101
|
+
// The restore is parked here rather than chained onto refreshRepoBrowser's own
|
|
102
|
+
// await, because loadRepoBrowserTree returns immediately when another load is
|
|
103
|
+
// already in flight — see the tail of loadRepoBrowserTree.
|
|
104
|
+
let pendingRepoBrowserRestore = null;
|
|
105
|
+
let repoBrowserRefreshSeq = 0;
|
|
106
|
+
let repoBrowserRenderSuspended = 0;
|
|
107
|
+
let repoBrowserRenderDirty = false;
|
|
108
|
+
let lastTreeScrolledPath = null;
|
|
109
|
+
const filePreviewHistory = [];
|
|
110
|
+
|
|
111
|
+
function takePendingRepoBrowserRestore() {
|
|
112
|
+
const restore = pendingRepoBrowserRestore;
|
|
113
|
+
pendingRepoBrowserRestore = null;
|
|
114
|
+
return restore;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Bumping the seq also invalidates any restore already in flight.
|
|
118
|
+
function parkRepoBrowserRestore() {
|
|
119
|
+
pendingRepoBrowserRestore = {
|
|
120
|
+
path: String(repoBrowserState.currentPath || ''),
|
|
121
|
+
seq: (repoBrowserRefreshSeq += 1),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Rehydrating a branch calls renderRepoBrowser twice per directory, so a deep
|
|
127
|
+
* restore would otherwise rebuild the tree's innerHTML dozens of times and
|
|
128
|
+
* flicker. Renders are collapsed into one at the end instead.
|
|
129
|
+
*/
|
|
130
|
+
async function withSuspendedRepoRender(fn) {
|
|
131
|
+
repoBrowserRenderSuspended += 1;
|
|
132
|
+
try {
|
|
133
|
+
return await fn();
|
|
134
|
+
} finally {
|
|
135
|
+
repoBrowserRenderSuspended -= 1;
|
|
136
|
+
if (repoBrowserRenderSuspended === 0 && repoBrowserRenderDirty) {
|
|
137
|
+
repoBrowserRenderDirty = false;
|
|
138
|
+
renderRepoBrowser();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveAttachmentContentUrl(rawValue) {
|
|
144
|
+
const value = String(rawValue || '').trim();
|
|
145
|
+
if (!value) return '';
|
|
146
|
+
if (/^(?:https?:|data:|blob:)/i.test(value)) return value;
|
|
147
|
+
const normalizedBase = String(BASE || '').trim().replace(/\/+$/, '');
|
|
148
|
+
if (normalizedBase && value.startsWith(`${normalizedBase}/`)) return value;
|
|
149
|
+
if (value.startsWith('/')) return `${BASE}${value}`;
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function flushQueuedRepoBrowserReload() {
|
|
154
|
+
if (!repoBrowserState.open) {
|
|
155
|
+
repoBrowserReloadQueued = false;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!repoBrowserReloadQueued || repoBrowserState.loading) return;
|
|
159
|
+
repoBrowserReloadQueued = false;
|
|
160
|
+
void loadRepoBrowserTree();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function renderAttachmentMarkup(attachments, { messageId = '' } = {}) {
|
|
164
|
+
return `<div class="msg-attachments">${
|
|
165
|
+
attachments.map((att) => {
|
|
166
|
+
const name = escHtml(att?.name || 'attachment');
|
|
167
|
+
const type = escHtml(att?.type || 'file');
|
|
168
|
+
const rawUrl = att?.dataUrl
|
|
169
|
+
? att.dataUrl
|
|
170
|
+
: att?.contentUrl
|
|
171
|
+
? resolveAttachmentContentUrl(att.contentUrl)
|
|
172
|
+
: '';
|
|
173
|
+
const isImage = String(att?.type || '').startsWith('image/');
|
|
174
|
+
const isVideo = isVideoMimeType(att?.type);
|
|
175
|
+
const sizeText = Number(att?.size || 0) > 0 ? ` · ${formatBytes(Number(att.size || 0))}` : '';
|
|
176
|
+
const continuity = att?.generatedImage?.continuity;
|
|
177
|
+
const continuityActions = continuity?.canEdit && messageId
|
|
178
|
+
? `<div class="msg-image-continuity-actions">
|
|
179
|
+
<button type="button" onclick="setImageEditTarget(${escHtml(JSON.stringify({
|
|
180
|
+
messageId,
|
|
181
|
+
imageId: att.generatedImage.imageId,
|
|
182
|
+
nodeId: continuity.nodeId,
|
|
183
|
+
name: att.name || 'generated image',
|
|
184
|
+
}))})">Edit this image</button>
|
|
185
|
+
${continuity.parentMessageId
|
|
186
|
+
? `<button type="button" onclick="jumpToImageParent(${escHtml(JSON.stringify(continuity.parentMessageId))})">Edited from previous image</button>`
|
|
187
|
+
: ''}
|
|
188
|
+
</div>`
|
|
189
|
+
: '';
|
|
190
|
+
if ((isImage || isVideo) && rawUrl) {
|
|
191
|
+
const jsName = escHtml(JSON.stringify(att?.name || 'attachment'));
|
|
192
|
+
const jsUrl = escHtml(JSON.stringify(rawUrl));
|
|
193
|
+
const jsType = escHtml(JSON.stringify(att?.type || 'image/jpeg'));
|
|
194
|
+
const openHandler = `openUploadedAttachmentViewer(${jsName},${jsUrl},${jsType})`;
|
|
195
|
+
return `
|
|
196
|
+
<div class="msg-attachment msg-attachment-${isVideo ? 'video' : 'image'}">
|
|
197
|
+
${isVideo
|
|
198
|
+
? `<div class="msg-attachment-video-chip">🎞️</div>`
|
|
199
|
+
: `<img src="${escHtml(rawUrl)}" alt="${name}" loading="eager" decoding="async" onclick="${openHandler}">`}
|
|
200
|
+
<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>
|
|
201
|
+
</div>`;
|
|
202
|
+
}
|
|
203
|
+
if (rawUrl) {
|
|
204
|
+
return `
|
|
205
|
+
<div class="msg-attachment">
|
|
206
|
+
<div class="msg-attachment-meta">📎 <a href="${escHtml(rawUrl)}" target="_blank" rel="noopener noreferrer">${name}</a> · ${type}${sizeText}</div>
|
|
207
|
+
</div>`;
|
|
208
|
+
}
|
|
209
|
+
return `
|
|
210
|
+
<div class="msg-attachment">
|
|
211
|
+
<div class="msg-attachment-meta">📎 ${name} · ${type}${sizeText}</div>
|
|
212
|
+
</div>`;
|
|
213
|
+
}).join('')
|
|
214
|
+
}</div>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function renderAttachmentPreview() {
|
|
218
|
+
const el = document.getElementById('attachment-preview');
|
|
219
|
+
if (!selectedAttachments.length) {
|
|
220
|
+
el.innerHTML = '';
|
|
221
|
+
el.classList.remove('visible');
|
|
222
|
+
window.syncComposerControlState?.();
|
|
223
|
+
syncComposerAttachmentWarning();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
el.innerHTML = selectedAttachments.map((att, idx) => {
|
|
228
|
+
const state = String(att?.uploadState || 'uploaded');
|
|
229
|
+
const thumb = att.isImage && att.previewUrl
|
|
230
|
+
? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}">`
|
|
231
|
+
: `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
|
|
232
|
+
const overlay = state === 'pending' || state === 'uploading'
|
|
233
|
+
? `<div class="attachment-preview-status attachment-preview-status-uploading" role="status" aria-label="Uploading ${escHtml(att.name)}"><span class="attachment-preview-spinner"></span></div>`
|
|
234
|
+
: state === 'error'
|
|
235
|
+
? `<button type="button" class="attachment-preview-status attachment-preview-status-error" onclick="retryAttachmentUpload(${idx})" title="${escHtml(att.error || 'Upload failed')} — click to retry">⟳</button>`
|
|
236
|
+
: '';
|
|
237
|
+
return `
|
|
238
|
+
<div class="attachment-preview-item attachment-preview-${escHtml(state)}">
|
|
239
|
+
<button class="attachment-preview-remove" onclick="removeAttachment(${idx})" title="Remove">×</button>
|
|
240
|
+
${thumb}
|
|
241
|
+
${overlay}
|
|
242
|
+
<div class="attachment-preview-meta">${escHtml(att.name)}${att.size ? ` · ${formatBytes(att.size)}` : ''}</div>
|
|
243
|
+
</div>
|
|
244
|
+
`;
|
|
245
|
+
}).join('');
|
|
246
|
+
el.classList.add('visible');
|
|
247
|
+
window.syncComposerControlState?.();
|
|
248
|
+
syncComposerAttachmentWarning();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function syncComposerAttachmentWarning() {
|
|
252
|
+
const el = document.getElementById('composer-attachment-warning');
|
|
253
|
+
if (!el) return;
|
|
254
|
+
const modelId = document.getElementById('model-select')?.value || '';
|
|
255
|
+
const text = imageAttachmentWarningText(modelId, selectedAttachments);
|
|
256
|
+
el.textContent = text;
|
|
257
|
+
el.hidden = !text;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function refreshComposerAttachmentWarning() {
|
|
261
|
+
syncComposerAttachmentWarning();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function releaseAttachmentPreviewUrl(attachment) {
|
|
265
|
+
// Hydrated draft attachments point at a server URL, which must not be revoked.
|
|
266
|
+
if (!attachment?.previewUrl) return;
|
|
267
|
+
if (attachment.previewUrlIsObjectUrl === false) return;
|
|
268
|
+
URL.revokeObjectURL(attachment.previewUrl);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function removeAttachment(idx) {
|
|
272
|
+
const [removed] = selectedAttachments.splice(idx, 1);
|
|
273
|
+
releaseAttachmentPreviewUrl(removed);
|
|
274
|
+
renderAttachmentPreview();
|
|
275
|
+
// Persisting the remaining list lets the server diff it and release the draft
|
|
276
|
+
// reference itself. Going through the normal draft save keeps removals ordered
|
|
277
|
+
// against in-flight draft writes instead of racing them.
|
|
278
|
+
window.persistComposerAttachments?.();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function clearAttachments() {
|
|
282
|
+
for (const att of selectedAttachments) {
|
|
283
|
+
releaseAttachmentPreviewUrl(att);
|
|
284
|
+
}
|
|
285
|
+
selectedAttachments.length = 0;
|
|
286
|
+
renderAttachmentPreview();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Replaces composer attachments wholesale, used when hydrating a conversation's
|
|
291
|
+
* cached draft attachments. Object URLs from the outgoing set are released.
|
|
292
|
+
*/
|
|
293
|
+
export function setComposerAttachments(attachments = []) {
|
|
294
|
+
for (const att of selectedAttachments) {
|
|
295
|
+
releaseAttachmentPreviewUrl(att);
|
|
296
|
+
}
|
|
297
|
+
selectedAttachments.length = 0;
|
|
298
|
+
selectedAttachments.push(...(Array.isArray(attachments) ? attachments.filter(Boolean) : []));
|
|
299
|
+
renderAttachmentPreview();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let attachmentIdSeq = 0;
|
|
303
|
+
|
|
304
|
+
function nextAttachmentId() {
|
|
305
|
+
attachmentIdSeq += 1;
|
|
306
|
+
return `att-${Date.now()}-${attachmentIdSeq}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function prepareIncomingFile(file, { source, now, index }) {
|
|
310
|
+
const rawType = String(file?.type || '').trim().toLowerCase() || 'application/octet-stream';
|
|
311
|
+
const isImage = rawType.startsWith('image/');
|
|
312
|
+
|
|
313
|
+
// Clipboard bitmaps arrive unnamed (or as a generic "image.png"), so they get a
|
|
314
|
+
// timestamped name that survives the round trip to the server.
|
|
315
|
+
const needsGeneratedName = source === 'paste' && isGenericClipboardName(file?.name);
|
|
316
|
+
let prepared = file;
|
|
317
|
+
// Re-encode when the image is oversized, and also when its format is one the
|
|
318
|
+
// model cannot read (a small WebP would otherwise arrive invisible).
|
|
319
|
+
if (isImage && reencodeReason(file)) {
|
|
320
|
+
prepared = await downscaleImageFile(file, {}, browserDownscaleDeps());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const finalType = String(prepared?.type || rawType).trim().toLowerCase() || 'application/octet-stream';
|
|
324
|
+
const finalIsImage = finalType.startsWith('image/');
|
|
325
|
+
const name = needsGeneratedName
|
|
326
|
+
? pastedFileName(finalType, now, index)
|
|
327
|
+
: String(prepared?.name || file?.name || 'upload');
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
id: nextAttachmentId(),
|
|
331
|
+
name,
|
|
332
|
+
type: finalType,
|
|
333
|
+
size: Number(prepared?.size || 0),
|
|
334
|
+
file: prepared,
|
|
335
|
+
isImage: finalIsImage,
|
|
336
|
+
previewUrl: finalIsImage ? URL.createObjectURL(prepared) : '',
|
|
337
|
+
previewUrlIsObjectUrl: finalIsImage,
|
|
338
|
+
uploadState: 'pending',
|
|
339
|
+
uploaded: null,
|
|
340
|
+
error: '',
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function startAttachmentUpload(attachment, ownerConversationId = currentConversationId()) {
|
|
345
|
+
if (!attachment?.file) return;
|
|
346
|
+
attachment.uploadState = 'uploading';
|
|
347
|
+
attachment.error = '';
|
|
348
|
+
renderAttachmentPreview();
|
|
349
|
+
try {
|
|
350
|
+
const payload = await uploadAttachment(attachment);
|
|
351
|
+
if (!payload?.attachment) throw new Error('Upload returned no attachment');
|
|
352
|
+
// The entry may have been removed, or the user may have moved to another
|
|
353
|
+
// conversation, while the upload was in flight.
|
|
354
|
+
if (!selectedAttachments.includes(attachment)) return;
|
|
355
|
+
attachment.uploaded = payload.attachment;
|
|
356
|
+
attachment.sha256 = payload.attachment.sha256;
|
|
357
|
+
attachment.uploadState = 'uploaded';
|
|
358
|
+
attachment.error = '';
|
|
359
|
+
renderAttachmentPreview();
|
|
360
|
+
if (currentConversationId() !== ownerConversationId) return;
|
|
361
|
+
window.persistComposerAttachments?.();
|
|
362
|
+
} catch (e) {
|
|
363
|
+
if (!selectedAttachments.includes(attachment)) return;
|
|
364
|
+
attachment.uploadState = 'error';
|
|
365
|
+
attachment.error = e?.message || 'Upload failed';
|
|
366
|
+
renderAttachmentPreview();
|
|
367
|
+
showTransientRelayNotice(`Upload failed for ${attachment.name}. Click the chip to retry.`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function retryAttachmentUpload(idx) {
|
|
372
|
+
const attachment = selectedAttachments[idx];
|
|
373
|
+
if (!attachment || attachment.uploadState !== 'error') return;
|
|
374
|
+
void startAttachmentUpload(attachment);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Single ingestion path shared by the file picker, paste and drag-and-drop.
|
|
379
|
+
* Files are normalized, optionally downscaled, capped, and uploaded immediately
|
|
380
|
+
* so that pressing Send never has to wait on the network.
|
|
381
|
+
*/
|
|
382
|
+
export async function ingestFiles(files, { source = 'picker' } = {}) {
|
|
383
|
+
const inputFiles = Array.from(files || []).filter(Boolean);
|
|
384
|
+
if (!inputFiles.length) return [];
|
|
385
|
+
|
|
386
|
+
// Downscaling a large image takes long enough for the user to switch
|
|
387
|
+
// conversations, and the result must never land in the wrong composer.
|
|
388
|
+
const ownerConversationId = currentConversationId();
|
|
389
|
+
const now = new Date();
|
|
390
|
+
const prepared = [];
|
|
391
|
+
for (let index = 0; index < inputFiles.length; index += 1) {
|
|
392
|
+
prepared.push(await prepareIncomingFile(inputFiles[index], { source, now, index }));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (currentConversationId() !== ownerConversationId) {
|
|
396
|
+
for (const item of prepared) releaseAttachmentPreviewUrl(item);
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const plan = planAttachmentMerge(selectedAttachments, prepared, MAX_UPLOAD_ATTACHMENTS);
|
|
401
|
+
const rejected = prepared.filter((item) => !plan.acceptedAdditions.includes(item));
|
|
402
|
+
for (const item of rejected) {
|
|
403
|
+
releaseAttachmentPreviewUrl(item);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
selectedAttachments.length = 0;
|
|
407
|
+
selectedAttachments.push(...plan.accepted);
|
|
408
|
+
renderAttachmentPreview();
|
|
409
|
+
|
|
410
|
+
if (plan.droppedCount > 0) {
|
|
411
|
+
showTransientRelayNotice(overCapNoticeText(plan.droppedCount, MAX_UPLOAD_ATTACHMENTS));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
await Promise.all(plan.acceptedAdditions.map(
|
|
415
|
+
(attachment) => startAttachmentUpload(attachment, ownerConversationId),
|
|
416
|
+
));
|
|
417
|
+
return plan.acceptedAdditions;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function handleAttachmentInput(files) {
|
|
421
|
+
return ingestFiles(files, { source: 'picker' });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function handleComposerPaste(event) {
|
|
425
|
+
const { files } = extractPastedFiles(event?.clipboardData);
|
|
426
|
+
if (!files.length) return false;
|
|
427
|
+
// Only claim the event once a file is actually present, so pasting plain text
|
|
428
|
+
// keeps its native behaviour.
|
|
429
|
+
event.preventDefault?.();
|
|
430
|
+
await ingestFiles(files, { source: 'paste' });
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function handleComposerDrop(event) {
|
|
435
|
+
const { files } = extractDroppedFiles(event?.dataTransfer);
|
|
436
|
+
if (!files.length) return false;
|
|
437
|
+
event.preventDefault?.();
|
|
438
|
+
await ingestFiles(files, { source: 'drop' });
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function uploadAttachments(files) {
|
|
443
|
+
const items = Array.isArray(files) ? files : [];
|
|
444
|
+
const uploaded = [];
|
|
445
|
+
for (const item of items) {
|
|
446
|
+
if (!item) continue;
|
|
447
|
+
// Eagerly uploaded attachments already hold their server payload; only
|
|
448
|
+
// entries that never made it (offline, failed, still queued) are retried.
|
|
449
|
+
if (item.uploaded?.sha256) {
|
|
450
|
+
uploaded.push(item.uploaded);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (!item.file) continue;
|
|
454
|
+
const payload = await uploadAttachment(item);
|
|
455
|
+
if (!payload?.attachment) throw new Error('Upload returned no attachment');
|
|
456
|
+
item.uploaded = payload.attachment;
|
|
457
|
+
item.sha256 = payload.attachment.sha256;
|
|
458
|
+
item.uploadState = 'uploaded';
|
|
459
|
+
uploaded.push(payload.attachment);
|
|
460
|
+
}
|
|
461
|
+
return uploaded;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function updateFilePreviewUiState() {
|
|
465
|
+
const previewBtn = document.getElementById('file-preview-mode-preview');
|
|
466
|
+
const rawBtn = document.getElementById('file-preview-mode-raw');
|
|
467
|
+
const htmlBtn = document.getElementById('file-preview-html-toggle');
|
|
468
|
+
const warning = document.getElementById('file-preview-warning');
|
|
469
|
+
const payload = filePreviewState.payload;
|
|
470
|
+
const isMarkdown = payload?.kind === 'markdown';
|
|
471
|
+
const isUpload = filePreviewState.source === 'upload';
|
|
472
|
+
previewBtn.style.display = isUpload ? 'none' : '';
|
|
473
|
+
rawBtn.style.display = isUpload ? 'none' : '';
|
|
474
|
+
previewBtn.classList.toggle('active', filePreviewState.mode === 'preview');
|
|
475
|
+
rawBtn.classList.toggle('active', filePreviewState.mode === 'raw');
|
|
476
|
+
htmlBtn.style.display = (!isUpload && isMarkdown && filePreviewState.mode === 'preview') ? 'inline-block' : 'none';
|
|
477
|
+
htmlBtn.classList.toggle('active', filePreviewState.allowHtml);
|
|
478
|
+
htmlBtn.textContent = filePreviewState.allowHtml ? 'Show literal HTML' : 'Show HTML layout';
|
|
479
|
+
warning.classList.toggle('visible', isMarkdown && filePreviewState.allowHtml && filePreviewState.mode === 'preview');
|
|
480
|
+
const backBtn = document.getElementById('file-preview-back');
|
|
481
|
+
backBtn.hidden = filePreviewHistory.length === 0;
|
|
482
|
+
backBtn.disabled = filePreviewHistory.length === 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function snapshotFilePreviewState() {
|
|
486
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
487
|
+
return {
|
|
488
|
+
path: filePreviewState.path,
|
|
489
|
+
source: filePreviewState.source,
|
|
490
|
+
mode: filePreviewState.mode,
|
|
491
|
+
allowHtml: filePreviewState.allowHtml,
|
|
492
|
+
loading: filePreviewState.loading,
|
|
493
|
+
error: filePreviewState.error,
|
|
494
|
+
payload: filePreviewState.payload,
|
|
495
|
+
viewerOptions: filePreviewState.viewerOptions,
|
|
496
|
+
scrollTop: Number(bodyEl?.scrollTop || 0),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function scrollMarkdownPreviewToFragment(rawFragment) {
|
|
501
|
+
const fragment = markdownHeadingId(rawFragment);
|
|
502
|
+
if (!fragment) return;
|
|
503
|
+
const article = document.querySelector('#file-preview-body .file-preview-markdown');
|
|
504
|
+
const target = Array.from(article?.querySelectorAll('[id]') || []).find((element) => element.id === fragment);
|
|
505
|
+
target?.scrollIntoView({ block: 'start' });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function assignMarkdownHeadingIds(article) {
|
|
509
|
+
const counts = new Map();
|
|
510
|
+
for (const heading of article.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
511
|
+
const baseId = markdownHeadingId(heading.textContent);
|
|
512
|
+
if (!baseId) continue;
|
|
513
|
+
const count = counts.get(baseId) || 0;
|
|
514
|
+
counts.set(baseId, count + 1);
|
|
515
|
+
heading.id = count ? `${baseId}-${count}` : baseId;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function teardownImageZoom() {
|
|
520
|
+
const c = imgZoom.container;
|
|
521
|
+
const img = imgZoom.imgEl;
|
|
522
|
+
const onImgLoad = imgZoom.onImgLoad;
|
|
523
|
+
window.removeEventListener('resize', _imgZoomOnResize);
|
|
524
|
+
if (img && onImgLoad) img.removeEventListener('load', onImgLoad);
|
|
525
|
+
if (c) {
|
|
526
|
+
c.removeEventListener('wheel', _imgZoomWheel);
|
|
527
|
+
c.removeEventListener('mousedown', _imgZoomMouseDown);
|
|
528
|
+
c.removeEventListener('dblclick', _imgZoomDblClick);
|
|
529
|
+
c.removeEventListener('touchstart', _imgZoomTouchStart);
|
|
530
|
+
c.removeEventListener('touchmove', _imgZoomTouchMove);
|
|
531
|
+
c.removeEventListener('touchend', _imgZoomTouchEnd);
|
|
532
|
+
imgZoom.container = null;
|
|
533
|
+
}
|
|
534
|
+
imgZoom.imgEl = null;
|
|
535
|
+
imgZoom.onImgLoad = null;
|
|
536
|
+
imgZoom.baseW = 0;
|
|
537
|
+
imgZoom.baseH = 0;
|
|
538
|
+
_cancelImgZoomCrispen();
|
|
539
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
540
|
+
if (bodyEl) bodyEl.classList.remove('image-zoom-mode');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function teardownVideoPreview() {
|
|
544
|
+
const video = videoPreview.videoEl;
|
|
545
|
+
const onLoadedMetadata = videoPreview.onLoadedMetadata;
|
|
546
|
+
const onError = videoPreview.onError;
|
|
547
|
+
const onCanPlay = videoPreview.onCanPlay;
|
|
548
|
+
if (video) {
|
|
549
|
+
if (onLoadedMetadata) video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
550
|
+
if (onError) video.removeEventListener('error', onError);
|
|
551
|
+
if (onCanPlay) video.removeEventListener('canplay', onCanPlay);
|
|
552
|
+
try {
|
|
553
|
+
video.pause();
|
|
554
|
+
} catch {}
|
|
555
|
+
}
|
|
556
|
+
videoPreview.videoEl = null;
|
|
557
|
+
videoPreview.onLoadedMetadata = null;
|
|
558
|
+
videoPreview.onError = null;
|
|
559
|
+
videoPreview.onCanPlay = null;
|
|
560
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
561
|
+
if (bodyEl) bodyEl.classList.remove('video-preview-mode');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function setupImageZoom(container) {
|
|
565
|
+
imgZoom.container = container;
|
|
566
|
+
imgZoom.imgEl = container.querySelector('img');
|
|
567
|
+
// Stale from the previously viewed image, and wrong for this one.
|
|
568
|
+
imgZoom.baseW = 0;
|
|
569
|
+
imgZoom.baseH = 0;
|
|
570
|
+
imgZoom.minScale = 1;
|
|
571
|
+
imgZoom.scale = 1;
|
|
572
|
+
imgZoom.panX = 0;
|
|
573
|
+
imgZoom.panY = 0;
|
|
574
|
+
imgZoom.dragging = false;
|
|
575
|
+
imgZoom.pinching = false;
|
|
576
|
+
imgZoom.lastTapMs = 0;
|
|
577
|
+
container.addEventListener('wheel', _imgZoomWheel, { passive: false });
|
|
578
|
+
container.addEventListener('mousedown', _imgZoomMouseDown);
|
|
579
|
+
container.addEventListener('dblclick', _imgZoomDblClick);
|
|
580
|
+
container.addEventListener('touchstart', _imgZoomTouchStart, { passive: false });
|
|
581
|
+
container.addEventListener('touchmove', _imgZoomTouchMove, { passive: false });
|
|
582
|
+
container.addEventListener('touchend', _imgZoomTouchEnd);
|
|
583
|
+
window.addEventListener('resize', _imgZoomOnResize);
|
|
584
|
+
if (imgZoom.imgEl) {
|
|
585
|
+
imgZoom.onImgLoad = () => _recomputeImgZoomMinScale({ resetToMin: true });
|
|
586
|
+
imgZoom.imgEl.addEventListener('load', imgZoom.onImgLoad, { once: true });
|
|
587
|
+
}
|
|
588
|
+
requestAnimationFrame(() => _recomputeImgZoomMinScale({ resetToMin: true }));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let imgZoom = {
|
|
592
|
+
scale: 1,
|
|
593
|
+
panX: 0,
|
|
594
|
+
panY: 0,
|
|
595
|
+
minScale: 1,
|
|
596
|
+
dragging: false,
|
|
597
|
+
lastX: 0,
|
|
598
|
+
lastY: 0,
|
|
599
|
+
pinching: false,
|
|
600
|
+
pinchDist0: 0,
|
|
601
|
+
pinchScale0: 1,
|
|
602
|
+
pinchPanX0: 0,
|
|
603
|
+
pinchPanY0: 0,
|
|
604
|
+
pinchCX: 0,
|
|
605
|
+
pinchCY: 0,
|
|
606
|
+
lastTapMs: 0,
|
|
607
|
+
container: null,
|
|
608
|
+
imgEl: null,
|
|
609
|
+
onImgLoad: null,
|
|
610
|
+
baseW: 0,
|
|
611
|
+
baseH: 0,
|
|
612
|
+
};
|
|
613
|
+
let videoPreview = {
|
|
614
|
+
videoEl: null,
|
|
615
|
+
onLoadedMetadata: null,
|
|
616
|
+
onError: null,
|
|
617
|
+
onCanPlay: null,
|
|
618
|
+
};
|
|
619
|
+
const IMG_ZOOM_MIN_FLOOR = 0.05;
|
|
620
|
+
const IMG_ZOOM_MAX = 8;
|
|
621
|
+
const IMG_ZOOM_EPS = 0.001;
|
|
622
|
+
|
|
623
|
+
function _isAtImgZoomMin() {
|
|
624
|
+
return Math.abs(imgZoom.scale - imgZoom.minScale) <= IMG_ZOOM_EPS;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The image's laid-out size at scale 1, measured once with the zoom styles
|
|
629
|
+
* removed. Deriving it from the live rect instead would be circular now that
|
|
630
|
+
* zooming changes the element's layout size rather than only its transform.
|
|
631
|
+
*/
|
|
632
|
+
function _measureImgBaseSize() {
|
|
633
|
+
const img = imgZoom.container?.querySelector('img');
|
|
634
|
+
if (!img) return null;
|
|
635
|
+
const prev = { width: img.style.width, height: img.style.height, maxWidth: img.style.maxWidth, maxHeight: img.style.maxHeight, transform: img.style.transform };
|
|
636
|
+
img.style.width = '';
|
|
637
|
+
img.style.height = '';
|
|
638
|
+
img.style.maxWidth = '';
|
|
639
|
+
img.style.maxHeight = '';
|
|
640
|
+
img.style.transform = '';
|
|
641
|
+
const rect = img.getBoundingClientRect();
|
|
642
|
+
img.style.width = prev.width;
|
|
643
|
+
img.style.height = prev.height;
|
|
644
|
+
img.style.maxWidth = prev.maxWidth;
|
|
645
|
+
img.style.maxHeight = prev.maxHeight;
|
|
646
|
+
img.style.transform = prev.transform;
|
|
647
|
+
if (!rect.width || !rect.height) return null;
|
|
648
|
+
imgZoom.baseW = rect.width;
|
|
649
|
+
imgZoom.baseH = rect.height;
|
|
650
|
+
return { baseW: rect.width, baseH: rect.height };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function _getImgBaseSize() {
|
|
654
|
+
if (imgZoom.baseW && imgZoom.baseH) return { baseW: imgZoom.baseW, baseH: imgZoom.baseH };
|
|
655
|
+
return _measureImgBaseSize();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let imgZoomCrispenTimer = null;
|
|
659
|
+
|
|
660
|
+
function _cancelImgZoomCrispen() {
|
|
661
|
+
if (!imgZoomCrispenTimer) return;
|
|
662
|
+
clearTimeout(imgZoomCrispenTimer);
|
|
663
|
+
imgZoomCrispenTimer = null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Re-layout only once the gesture stops, so a wheel spin does not trigger a
|
|
667
|
+
// full resample of a large bitmap on every tick.
|
|
668
|
+
function _scheduleImgZoomCrispen() {
|
|
669
|
+
_cancelImgZoomCrispen();
|
|
670
|
+
imgZoomCrispenTimer = setTimeout(() => {
|
|
671
|
+
imgZoomCrispenTimer = null;
|
|
672
|
+
if (!imgZoom.container) return;
|
|
673
|
+
_applyImgZoom({ crisp: true });
|
|
674
|
+
}, 180);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function _recomputeImgZoomMinScale({ resetToMin = false } = {}) {
|
|
678
|
+
if (!imgZoom.container) return;
|
|
679
|
+
const cW = imgZoom.container.clientWidth;
|
|
680
|
+
const cH = imgZoom.container.clientHeight;
|
|
681
|
+
const size = _getImgBaseSize();
|
|
682
|
+
if (!cW || !cH || !size || !size.baseW || !size.baseH) return;
|
|
683
|
+
const fitScaleX = cW / size.baseW;
|
|
684
|
+
const fitScaleY = cH / size.baseH;
|
|
685
|
+
const nextMinScale = Math.max(IMG_ZOOM_MIN_FLOOR, Math.min(1, fitScaleX, fitScaleY));
|
|
686
|
+
const wasAtMin = _isAtImgZoomMin();
|
|
687
|
+
imgZoom.minScale = nextMinScale;
|
|
688
|
+
if (resetToMin || wasAtMin || imgZoom.scale < imgZoom.minScale) {
|
|
689
|
+
imgZoom.scale = imgZoom.minScale;
|
|
690
|
+
imgZoom.panX = 0;
|
|
691
|
+
imgZoom.panY = 0;
|
|
692
|
+
}
|
|
693
|
+
_clampImgZoom();
|
|
694
|
+
_applyImgZoom();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Zooming happens in two stages.
|
|
699
|
+
*
|
|
700
|
+
* While the user is actively zooming, a CSS transform is used: it is GPU cheap
|
|
701
|
+
* and stays smooth even on an 8-megapixel screenshot. Once the gesture settles,
|
|
702
|
+
* the image is re-laid-out at its zoomed size instead, which forces the browser
|
|
703
|
+
* to resample from the full-resolution bitmap.
|
|
704
|
+
*
|
|
705
|
+
* The second stage matters because a transform only magnifies whatever raster
|
|
706
|
+
* the compositor already has. Desktop Chromium happens to re-rasterise and stays
|
|
707
|
+
* sharp, but that is not guaranteed — devices that cap decoded image resolution
|
|
708
|
+
* to save memory, which is common on phones, keep magnifying the reduced raster
|
|
709
|
+
* and fine text turns to mush.
|
|
710
|
+
*/
|
|
711
|
+
function _applyImgZoom({ crisp = false } = {}) {
|
|
712
|
+
const img = imgZoom.container?.querySelector('img');
|
|
713
|
+
if (!img) return;
|
|
714
|
+
|
|
715
|
+
const size = _getImgBaseSize();
|
|
716
|
+
const atRest = Math.abs(imgZoom.scale - imgZoom.minScale) <= IMG_ZOOM_EPS
|
|
717
|
+
&& imgZoom.panX === 0 && imgZoom.panY === 0;
|
|
718
|
+
const panOnly = imgZoom.panX === 0 && imgZoom.panY === 0
|
|
719
|
+
? ''
|
|
720
|
+
: `translate(${imgZoom.panX}px,${imgZoom.panY}px)`;
|
|
721
|
+
|
|
722
|
+
if (crisp && size && !atRest) {
|
|
723
|
+
img.style.maxWidth = 'none';
|
|
724
|
+
img.style.maxHeight = 'none';
|
|
725
|
+
img.style.width = `${size.baseW * imgZoom.scale}px`;
|
|
726
|
+
img.style.height = `${size.baseH * imgZoom.scale}px`;
|
|
727
|
+
img.style.transform = panOnly;
|
|
728
|
+
} else {
|
|
729
|
+
img.style.maxWidth = '';
|
|
730
|
+
img.style.maxHeight = '';
|
|
731
|
+
img.style.width = '';
|
|
732
|
+
img.style.height = '';
|
|
733
|
+
img.style.transform = atRest
|
|
734
|
+
? ''
|
|
735
|
+
: `${panOnly} scale(${imgZoom.scale})`.trim();
|
|
736
|
+
_scheduleImgZoomCrispen();
|
|
737
|
+
}
|
|
738
|
+
imgZoom.container.style.cursor = imgZoom.dragging ? 'grabbing'
|
|
739
|
+
: imgZoom.scale > (imgZoom.minScale + IMG_ZOOM_EPS) ? 'grab' : 'zoom-in';
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function _clampImgZoom() {
|
|
743
|
+
imgZoom.scale = Math.max(imgZoom.minScale, Math.min(IMG_ZOOM_MAX, imgZoom.scale));
|
|
744
|
+
const size = _getImgBaseSize();
|
|
745
|
+
if (size && imgZoom.container) {
|
|
746
|
+
const cW = imgZoom.container.clientWidth;
|
|
747
|
+
const cH = imgZoom.container.clientHeight;
|
|
748
|
+
const scaledW = size.baseW * imgZoom.scale;
|
|
749
|
+
const scaledH = size.baseH * imgZoom.scale;
|
|
750
|
+
const maxPanX = Math.max(0, (scaledW - cW) / 2);
|
|
751
|
+
const maxPanY = Math.max(0, (scaledH - cH) / 2);
|
|
752
|
+
imgZoom.panX = Math.max(-maxPanX, Math.min(maxPanX, imgZoom.panX));
|
|
753
|
+
imgZoom.panY = Math.max(-maxPanY, Math.min(maxPanY, imgZoom.panY));
|
|
754
|
+
}
|
|
755
|
+
if (imgZoom.scale <= imgZoom.minScale + IMG_ZOOM_EPS) {
|
|
756
|
+
imgZoom.scale = imgZoom.minScale;
|
|
757
|
+
imgZoom.panX = 0;
|
|
758
|
+
imgZoom.panY = 0;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function _zoomAtPoint(factor, cxFromCenter, cyFromCenter) {
|
|
763
|
+
const old = imgZoom.scale;
|
|
764
|
+
imgZoom.scale = old * factor;
|
|
765
|
+
if (imgZoom.scale !== old) {
|
|
766
|
+
imgZoom.panX = cxFromCenter - (cxFromCenter - imgZoom.panX) * (imgZoom.scale / old);
|
|
767
|
+
imgZoom.panY = cyFromCenter - (cyFromCenter - imgZoom.panY) * (imgZoom.scale / old);
|
|
768
|
+
}
|
|
769
|
+
_clampImgZoom();
|
|
770
|
+
_applyImgZoom();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function _ptFromCenter(clientX, clientY) {
|
|
774
|
+
const r = imgZoom.container.getBoundingClientRect();
|
|
775
|
+
return { cx: clientX - r.left - r.width / 2, cy: clientY - r.top - r.height / 2 };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function _imgZoomWheel(e) {
|
|
779
|
+
e.preventDefault();
|
|
780
|
+
const { cx, cy } = _ptFromCenter(e.clientX, e.clientY);
|
|
781
|
+
_zoomAtPoint(e.deltaY < 0 ? 1.15 : 1 / 1.15, cx, cy);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function _imgZoomMouseDown(e) {
|
|
785
|
+
if (e.button !== 0 || _isAtImgZoomMin()) return;
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
imgZoom.dragging = true;
|
|
788
|
+
imgZoom.lastX = e.clientX;
|
|
789
|
+
imgZoom.lastY = e.clientY;
|
|
790
|
+
_applyImgZoom();
|
|
791
|
+
const onMove = (ev) => {
|
|
792
|
+
if (!imgZoom.dragging) return;
|
|
793
|
+
imgZoom.panX += ev.clientX - imgZoom.lastX;
|
|
794
|
+
imgZoom.panY += ev.clientY - imgZoom.lastY;
|
|
795
|
+
imgZoom.lastX = ev.clientX;
|
|
796
|
+
imgZoom.lastY = ev.clientY;
|
|
797
|
+
_clampImgZoom();
|
|
798
|
+
_applyImgZoom();
|
|
799
|
+
};
|
|
800
|
+
const onUp = () => {
|
|
801
|
+
imgZoom.dragging = false;
|
|
802
|
+
_applyImgZoom();
|
|
803
|
+
document.removeEventListener('mousemove', onMove);
|
|
804
|
+
document.removeEventListener('mouseup', onUp);
|
|
805
|
+
};
|
|
806
|
+
document.addEventListener('mousemove', onMove);
|
|
807
|
+
document.addEventListener('mouseup', onUp);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function _imgZoomDblClick(e) {
|
|
811
|
+
const { cx, cy } = _ptFromCenter(e.clientX, e.clientY);
|
|
812
|
+
if (!_isAtImgZoomMin() || imgZoom.panX !== 0 || imgZoom.panY !== 0) {
|
|
813
|
+
imgZoom.scale = imgZoom.minScale;
|
|
814
|
+
imgZoom.panX = 0;
|
|
815
|
+
imgZoom.panY = 0;
|
|
816
|
+
_applyImgZoom();
|
|
817
|
+
} else {
|
|
818
|
+
_zoomAtPoint(3, cx, cy);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function _imgZoomTouchStart(e) {
|
|
823
|
+
const now = Date.now();
|
|
824
|
+
if (e.touches.length === 2) {
|
|
825
|
+
e.preventDefault();
|
|
826
|
+
imgZoom.dragging = false;
|
|
827
|
+
imgZoom.pinching = true;
|
|
828
|
+
const t0 = e.touches[0], t1 = e.touches[1];
|
|
829
|
+
imgZoom.pinchDist0 = Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY);
|
|
830
|
+
imgZoom.pinchScale0 = imgZoom.scale;
|
|
831
|
+
imgZoom.pinchPanX0 = imgZoom.panX;
|
|
832
|
+
imgZoom.pinchPanY0 = imgZoom.panY;
|
|
833
|
+
const r = imgZoom.container.getBoundingClientRect();
|
|
834
|
+
imgZoom.pinchCX = (t0.clientX + t1.clientX) / 2 - r.left - r.width / 2;
|
|
835
|
+
imgZoom.pinchCY = (t0.clientY + t1.clientY) / 2 - r.top - r.height / 2;
|
|
836
|
+
} else if (e.touches.length === 1) {
|
|
837
|
+
if (now - imgZoom.lastTapMs < 300) {
|
|
838
|
+
e.preventDefault();
|
|
839
|
+
const { cx, cy } = _ptFromCenter(e.touches[0].clientX, e.touches[0].clientY);
|
|
840
|
+
if (!_isAtImgZoomMin() || imgZoom.panX !== 0 || imgZoom.panY !== 0) {
|
|
841
|
+
imgZoom.scale = imgZoom.minScale;
|
|
842
|
+
imgZoom.panX = 0;
|
|
843
|
+
imgZoom.panY = 0;
|
|
844
|
+
_applyImgZoom();
|
|
845
|
+
} else {
|
|
846
|
+
_zoomAtPoint(3, cx, cy);
|
|
847
|
+
}
|
|
848
|
+
imgZoom.lastTapMs = 0;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
imgZoom.lastTapMs = now;
|
|
852
|
+
if (!_isAtImgZoomMin()) {
|
|
853
|
+
e.preventDefault();
|
|
854
|
+
imgZoom.dragging = true;
|
|
855
|
+
imgZoom.lastX = e.touches[0].clientX;
|
|
856
|
+
imgZoom.lastY = e.touches[0].clientY;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function _imgZoomTouchMove(e) {
|
|
862
|
+
if (imgZoom.pinching && e.touches.length >= 2) {
|
|
863
|
+
e.preventDefault();
|
|
864
|
+
const t0 = e.touches[0], t1 = e.touches[1];
|
|
865
|
+
const dist = Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY);
|
|
866
|
+
const newScale = Math.max(imgZoom.minScale, Math.min(IMG_ZOOM_MAX, imgZoom.pinchScale0 * (dist / imgZoom.pinchDist0)));
|
|
867
|
+
imgZoom.scale = newScale;
|
|
868
|
+
imgZoom.panX = imgZoom.pinchCX - (imgZoom.pinchCX - imgZoom.pinchPanX0) * (newScale / imgZoom.pinchScale0);
|
|
869
|
+
imgZoom.panY = imgZoom.pinchCY - (imgZoom.pinchCY - imgZoom.pinchPanY0) * (newScale / imgZoom.pinchScale0);
|
|
870
|
+
_clampImgZoom();
|
|
871
|
+
_applyImgZoom();
|
|
872
|
+
} else if (imgZoom.dragging && e.touches.length === 1) {
|
|
873
|
+
e.preventDefault();
|
|
874
|
+
imgZoom.panX += e.touches[0].clientX - imgZoom.lastX;
|
|
875
|
+
imgZoom.panY += e.touches[0].clientY - imgZoom.lastY;
|
|
876
|
+
imgZoom.lastX = e.touches[0].clientX;
|
|
877
|
+
imgZoom.lastY = e.touches[0].clientY;
|
|
878
|
+
_clampImgZoom();
|
|
879
|
+
_applyImgZoom();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function _imgZoomTouchEnd(e) {
|
|
884
|
+
if (e.touches.length < 2) imgZoom.pinching = false;
|
|
885
|
+
if (e.touches.length === 0) {
|
|
886
|
+
imgZoom.dragging = false;
|
|
887
|
+
_applyImgZoom();
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function _imgZoomOnResize() {
|
|
892
|
+
if (!imgZoom.container) return;
|
|
893
|
+
// The unzoomed size is container-relative, so it has to be measured again.
|
|
894
|
+
imgZoom.baseW = 0;
|
|
895
|
+
imgZoom.baseH = 0;
|
|
896
|
+
_recomputeImgZoomMinScale({ resetToMin: _isAtImgZoomMin() });
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export function openUploadedAttachmentViewer(name, contentUrl, mimeType, options = {}) {
|
|
900
|
+
const isImage = String(mimeType || '').startsWith('image/');
|
|
901
|
+
const isVideo = isVideoMimeType(mimeType);
|
|
902
|
+
const viewerOptions = normalizeVideoPreviewOptions(options);
|
|
903
|
+
setFilePreviewState({
|
|
904
|
+
path: String(name || 'attachment'),
|
|
905
|
+
source: 'upload',
|
|
906
|
+
mode: 'preview',
|
|
907
|
+
allowHtml: false,
|
|
908
|
+
loading: false,
|
|
909
|
+
error: '',
|
|
910
|
+
viewerOptions,
|
|
911
|
+
payload: {
|
|
912
|
+
kind: isImage ? 'image' : (isVideo ? 'video' : 'binary'),
|
|
913
|
+
name: String(name || 'attachment'),
|
|
914
|
+
rawUrl: String(contentUrl || ''),
|
|
915
|
+
size: 0,
|
|
916
|
+
contentType: String(mimeType || '').toLowerCase(),
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
const modal = document.getElementById('file-preview-modal');
|
|
920
|
+
modal.classList.add('visible');
|
|
921
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
922
|
+
renderFilePreview();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export function setFilePreviewMode(mode) {
|
|
926
|
+
const nextMode = String(mode || '').toLowerCase();
|
|
927
|
+
if (nextMode !== 'preview' && nextMode !== 'raw') return;
|
|
928
|
+
filePreviewState.mode = nextMode;
|
|
929
|
+
renderFilePreview();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export function toggleFilePreviewHtml() {
|
|
933
|
+
if (filePreviewState.payload?.kind !== 'markdown') return;
|
|
934
|
+
filePreviewState.allowHtml = !filePreviewState.allowHtml;
|
|
935
|
+
renderFilePreview();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export function renderFilePreview() {
|
|
939
|
+
teardownImageZoom();
|
|
940
|
+
teardownVideoPreview();
|
|
941
|
+
const titleEl = document.getElementById('file-preview-title');
|
|
942
|
+
const metaEl = document.getElementById('file-preview-meta');
|
|
943
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
944
|
+
const rawLink = document.getElementById('file-preview-open-raw');
|
|
945
|
+
const payload = filePreviewState.payload;
|
|
946
|
+
const rawHref = filePreviewState.source === 'drives'
|
|
947
|
+
|| filePreviewState.source === 'session'
|
|
948
|
+
? driveFileHrefFromPath(filePreviewState.path)
|
|
949
|
+
: filePreviewState.source === 'upload'
|
|
950
|
+
? String(payload?.rawUrl || '')
|
|
951
|
+
: `${BASE}/api/files/${filePreviewState.path.split('/').map((s) => encodeURIComponent(s)).join('/')}${currentWorkspaceScopeSuffix()}`;
|
|
952
|
+
rawLink.href = rawHref || '#';
|
|
953
|
+
const fallbackName = String(filePreviewState.path || '').split('/').filter(Boolean).pop() || 'download';
|
|
954
|
+
rawLink.setAttribute('download', String(payload?.name || fallbackName));
|
|
955
|
+
rawLink.textContent = 'Download';
|
|
956
|
+
rawLink.setAttribute('title', 'Download file');
|
|
957
|
+
|
|
958
|
+
const titlePath = filePreviewState.path || '';
|
|
959
|
+
titleEl.textContent = titlePath || 'File preview';
|
|
960
|
+
const fileReferenceToken = titlePath ? buildReferenceToken('file', titlePath, filePreviewState.source) : '';
|
|
961
|
+
if (fileReferenceToken) {
|
|
962
|
+
titleEl.setAttribute('data-copy-reference', fileReferenceToken);
|
|
963
|
+
titleEl.setAttribute('title', `Click to copy \`${fileReferenceToken}\``);
|
|
964
|
+
titleEl.classList.add('file-preview-title-copyable');
|
|
965
|
+
} else {
|
|
966
|
+
titleEl.removeAttribute('data-copy-reference');
|
|
967
|
+
titleEl.removeAttribute('title');
|
|
968
|
+
titleEl.classList.remove('file-preview-title-copyable');
|
|
969
|
+
}
|
|
970
|
+
updateFilePreviewUiState();
|
|
971
|
+
|
|
972
|
+
if (filePreviewState.loading) {
|
|
973
|
+
metaEl.textContent = 'Loading preview...';
|
|
974
|
+
bodyEl.innerHTML = '<div class="file-preview-note">Fetching file preview…</div>';
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (filePreviewState.error) {
|
|
978
|
+
metaEl.textContent = 'Preview failed';
|
|
979
|
+
bodyEl.innerHTML = `<pre class="file-preview-code"><code>${escHtml(filePreviewState.error)}</code></pre>`;
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (!payload) {
|
|
983
|
+
metaEl.textContent = 'No preview data';
|
|
984
|
+
bodyEl.innerHTML = '<div class="file-preview-note">No preview available.</div>';
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const kindLabel = String(payload.kind || 'text').toUpperCase();
|
|
989
|
+
const langLabel = payload.language ? ` · ${payload.language}` : '';
|
|
990
|
+
const truncatedLabel = payload.truncated ? ` · truncated to ${formatBytes(FILE_PREVIEW_MAX_BYTES)}` : '';
|
|
991
|
+
metaEl.textContent = `${kindLabel}${langLabel} · ${formatBytes(payload.size || 0)}${truncatedLabel}`;
|
|
992
|
+
|
|
993
|
+
if (payload.kind === 'binary') {
|
|
994
|
+
bodyEl.innerHTML = '<div class="file-preview-note">Binary file preview is not shown. Use <b>Download</b> to save the file.</div>';
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (payload.kind === 'image') {
|
|
999
|
+
const imageHref = filePreviewState.source === 'upload'
|
|
1000
|
+
? String(payload.rawUrl || '')
|
|
1001
|
+
: String(rawHref || payload.rawUrl || '');
|
|
1002
|
+
if (imageHref) {
|
|
1003
|
+
bodyEl.innerHTML = `<div class="file-preview-image"><img loading="lazy" src="${escHtml(imageHref)}" alt="${escHtml(payload.name || filePreviewState.path || 'image')}"></div>`;
|
|
1004
|
+
bodyEl.classList.add('image-zoom-mode');
|
|
1005
|
+
setupImageZoom(bodyEl.querySelector('.file-preview-image'));
|
|
1006
|
+
} else {
|
|
1007
|
+
bodyEl.innerHTML = '<div class="file-preview-note">Image preview unavailable.</div>';
|
|
1008
|
+
}
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (filePreviewState.mode === 'raw') {
|
|
1013
|
+
bodyEl.innerHTML = payload.kind === 'video'
|
|
1014
|
+
? '<div class="file-preview-note">Video files are binary. Use <b>Download</b> to save the file.</div>'
|
|
1015
|
+
: `<div class="file-preview-code"><pre><code>${escHtml(String(payload.content || ''))}</code></pre></div>`;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (payload.kind === 'video') {
|
|
1020
|
+
const videoHref = String(rawHref || payload.rawUrl || '');
|
|
1021
|
+
const viewerOptions = filePreviewState.viewerOptions || {};
|
|
1022
|
+
const startSeconds = Math.max(0, Number(viewerOptions.startSeconds || 0) || 0);
|
|
1023
|
+
const preload = String(viewerOptions.preload || 'metadata').toLowerCase() === 'auto' ? 'auto' : 'metadata';
|
|
1024
|
+
const autoplay = viewerOptions.autoplay === true;
|
|
1025
|
+
if (videoHref) {
|
|
1026
|
+
bodyEl.innerHTML = `
|
|
1027
|
+
<div class="file-preview-video-shell" data-start-seconds="${escHtml(String(startSeconds))}" data-preload="${escHtml(preload)}">
|
|
1028
|
+
<video class="file-preview-video" controls playsinline preload="${escHtml(preload)}" src="${escHtml(videoHref)}"></video>
|
|
1029
|
+
<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>
|
|
1030
|
+
</div>`;
|
|
1031
|
+
bodyEl.classList.add('video-preview-mode');
|
|
1032
|
+
const shell = bodyEl.querySelector('.file-preview-video-shell');
|
|
1033
|
+
const video = shell?.querySelector('video');
|
|
1034
|
+
if (video) {
|
|
1035
|
+
videoPreview.videoEl = video;
|
|
1036
|
+
videoPreview.onLoadedMetadata = () => {
|
|
1037
|
+
if (startSeconds > 0 && Number.isFinite(video.duration) && video.duration > startSeconds) {
|
|
1038
|
+
try {
|
|
1039
|
+
video.currentTime = startSeconds;
|
|
1040
|
+
} catch {}
|
|
1041
|
+
}
|
|
1042
|
+
if (autoplay) {
|
|
1043
|
+
const playPromise = video.play?.();
|
|
1044
|
+
if (playPromise && typeof playPromise.catch === 'function') {
|
|
1045
|
+
playPromise.catch(() => {});
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
videoPreview.onCanPlay = () => {
|
|
1050
|
+
if (autoplay && video.paused) {
|
|
1051
|
+
const playPromise = video.play?.();
|
|
1052
|
+
if (playPromise && typeof playPromise.catch === 'function') {
|
|
1053
|
+
playPromise.catch(() => {});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
videoPreview.onError = () => {
|
|
1058
|
+
let errorNote = bodyEl.querySelector('.file-preview-video-error');
|
|
1059
|
+
if (!errorNote) {
|
|
1060
|
+
errorNote = document.createElement('div');
|
|
1061
|
+
errorNote.className = 'file-preview-note file-preview-video-error';
|
|
1062
|
+
bodyEl.appendChild(errorNote);
|
|
1063
|
+
}
|
|
1064
|
+
errorNote.innerHTML = 'Video preview unavailable. Use <b>Download</b> to save the file.';
|
|
1065
|
+
};
|
|
1066
|
+
video.addEventListener('loadedmetadata', videoPreview.onLoadedMetadata, { once: true });
|
|
1067
|
+
video.addEventListener('canplay', videoPreview.onCanPlay);
|
|
1068
|
+
video.addEventListener('error', videoPreview.onError, { once: true });
|
|
1069
|
+
}
|
|
1070
|
+
} else {
|
|
1071
|
+
bodyEl.innerHTML = '<div class="file-preview-note">Video preview unavailable.</div>';
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const rawText = String(payload.content || '');
|
|
1077
|
+
|
|
1078
|
+
if (payload.kind === 'markdown') {
|
|
1079
|
+
const html = renderMarkdownPreview(rawText, filePreviewState.allowHtml);
|
|
1080
|
+
bodyEl.innerHTML = `<article class="file-preview-markdown">${html}</article>`;
|
|
1081
|
+
const article = bodyEl.querySelector('.file-preview-markdown');
|
|
1082
|
+
rewriteLocalAssetUrlsInNode(article, {
|
|
1083
|
+
preferDrive: filePreviewState.source === 'drives' || filePreviewState.source === 'session',
|
|
1084
|
+
rewriteAnchors: false,
|
|
1085
|
+
});
|
|
1086
|
+
assignMarkdownHeadingIds(article);
|
|
1087
|
+
bodyEl.querySelectorAll('pre code').forEach((block) => hljs.highlightElement(block));
|
|
1088
|
+
attachCodeCopyButtons(bodyEl);
|
|
1089
|
+
const fragment = String(filePreviewState.viewerOptions?.fragment || '');
|
|
1090
|
+
if (fragment) requestAnimationFrame(() => scrollMarkdownPreviewToFragment(fragment));
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const languageClass = payload.language ? `language-${escHtml(payload.language)}` : '';
|
|
1095
|
+
bodyEl.innerHTML = `<div class="file-preview-code"><pre><code class="${languageClass}">${escHtml(rawText)}</code></pre></div>`;
|
|
1096
|
+
bodyEl.querySelectorAll('pre code').forEach((block) => hljs.highlightElement(block));
|
|
1097
|
+
attachCodeCopyButtons(bodyEl);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export async function openWorkspaceFilePreview(rawPath, options = {}) {
|
|
1101
|
+
const normalized = normalizeWorkspaceMentionPath(rawPath);
|
|
1102
|
+
if (!normalized) return;
|
|
1103
|
+
const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
|
|
1104
|
+
if (!options?.fromViewerLink) filePreviewHistory.length = 0;
|
|
1105
|
+
setFilePreviewState({
|
|
1106
|
+
path: normalized,
|
|
1107
|
+
source: 'workspace',
|
|
1108
|
+
mode: 'preview',
|
|
1109
|
+
allowHtml: true,
|
|
1110
|
+
loading: true,
|
|
1111
|
+
error: '',
|
|
1112
|
+
payload: null,
|
|
1113
|
+
viewerOptions,
|
|
1114
|
+
});
|
|
1115
|
+
const modal = document.getElementById('file-preview-modal');
|
|
1116
|
+
modal.classList.add('visible');
|
|
1117
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1118
|
+
renderFilePreview();
|
|
1119
|
+
|
|
1120
|
+
const payload = await loadWorkspaceFilePreview(normalized, currentConversationId());
|
|
1121
|
+
if (!payload || payload.error) {
|
|
1122
|
+
filePreviewState.loading = false;
|
|
1123
|
+
filePreviewState.error = payload?.error || 'Failed to load file preview';
|
|
1124
|
+
renderFilePreview();
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
filePreviewState.loading = false;
|
|
1128
|
+
filePreviewState.payload = payload;
|
|
1129
|
+
filePreviewState.path = String(payload.path || normalized);
|
|
1130
|
+
filePreviewState.viewerOptions = viewerOptions;
|
|
1131
|
+
renderFilePreview();
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export async function openDriveFilePreview(rawPath, options = {}) {
|
|
1136
|
+
const normalized = normalizeDriveBrowserPath(rawPath);
|
|
1137
|
+
if (!normalized) return;
|
|
1138
|
+
const viewerOptions = { ...normalizeVideoPreviewOptions(options), fragment: String(options?.fragment || '') };
|
|
1139
|
+
if (!options?.fromViewerLink) filePreviewHistory.length = 0;
|
|
1140
|
+
setFilePreviewState({
|
|
1141
|
+
path: normalized,
|
|
1142
|
+
source: 'drives',
|
|
1143
|
+
mode: 'preview',
|
|
1144
|
+
allowHtml: true,
|
|
1145
|
+
loading: true,
|
|
1146
|
+
error: '',
|
|
1147
|
+
payload: null,
|
|
1148
|
+
viewerOptions,
|
|
1149
|
+
});
|
|
1150
|
+
const modal = document.getElementById('file-preview-modal');
|
|
1151
|
+
modal.classList.add('visible');
|
|
1152
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1153
|
+
renderFilePreview();
|
|
1154
|
+
|
|
1155
|
+
const payload = await loadDriveFilePreview(normalized);
|
|
1156
|
+
if (!payload || payload.error) {
|
|
1157
|
+
filePreviewState.loading = false;
|
|
1158
|
+
filePreviewState.error = payload?.error || 'Failed to load drive file preview';
|
|
1159
|
+
renderFilePreview();
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
filePreviewState.loading = false;
|
|
1163
|
+
filePreviewState.payload = payload;
|
|
1164
|
+
filePreviewState.path = String(payload.path || normalized);
|
|
1165
|
+
filePreviewState.source = 'drives';
|
|
1166
|
+
filePreviewState.viewerOptions = viewerOptions;
|
|
1167
|
+
renderFilePreview();
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export async function openWorkspaceFilePreviewFromRepo(rawPath, options = {}) {
|
|
1172
|
+
if (repoBrowserState.activeRoot !== 'workspace') {
|
|
1173
|
+
await openDriveFilePreview(rawPath, options);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
await openWorkspaceFilePreview(rawPath, options);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
export function closeFilePreview() {
|
|
1180
|
+
teardownImageZoom();
|
|
1181
|
+
teardownVideoPreview();
|
|
1182
|
+
const modal = document.getElementById('file-preview-modal');
|
|
1183
|
+
modal.classList.remove('visible');
|
|
1184
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
1185
|
+
filePreviewHistory.length = 0;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export function goBackFilePreview() {
|
|
1189
|
+
const previous = filePreviewHistory.pop();
|
|
1190
|
+
if (!previous) return;
|
|
1191
|
+
setFilePreviewState(previous);
|
|
1192
|
+
renderFilePreview();
|
|
1193
|
+
requestAnimationFrame(() => {
|
|
1194
|
+
const bodyEl = document.getElementById('file-preview-body');
|
|
1195
|
+
if (bodyEl) bodyEl.scrollTop = Number(previous.scrollTop || 0);
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function openFilePreviewLink(anchor) {
|
|
1200
|
+
const target = resolveFilePreviewLink(anchor.getAttribute('href'), filePreviewState.path);
|
|
1201
|
+
if (target.kind === 'fragment') {
|
|
1202
|
+
scrollMarkdownPreviewToFragment(target.fragment);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (target.kind !== 'file') return;
|
|
1206
|
+
|
|
1207
|
+
const snapshot = snapshotFilePreviewState();
|
|
1208
|
+
filePreviewHistory.push(snapshot);
|
|
1209
|
+
renderFilePreview();
|
|
1210
|
+
const loaded = filePreviewState.source === 'workspace'
|
|
1211
|
+
? await openWorkspaceFilePreview(target.path, { fromViewerLink: true, fragment: target.fragment })
|
|
1212
|
+
: await openDriveFilePreview(target.path, { fromViewerLink: true, fragment: target.fragment });
|
|
1213
|
+
if (loaded) return;
|
|
1214
|
+
filePreviewHistory.pop();
|
|
1215
|
+
setFilePreviewState(snapshot);
|
|
1216
|
+
renderFilePreview();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
export function normalizeRepoPath(pathValue) {
|
|
1220
|
+
if (pathValue === '' || pathValue === null || pathValue === undefined) return '';
|
|
1221
|
+
if (repoBrowserState.activeRoot !== 'workspace') {
|
|
1222
|
+
return normalizeDriveBrowserPath(pathValue);
|
|
1223
|
+
}
|
|
1224
|
+
return normalizeWorkspaceMentionPath(pathValue);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function getRepoBrowserLaunchCwdPath() {
|
|
1228
|
+
const currentPath = String(repoBrowserState.currentPath || '').trim();
|
|
1229
|
+
const activeWorkspaceRoot = currentWorkspaceRootPathForSelection();
|
|
1230
|
+
if (!currentPath) return activeWorkspaceRoot;
|
|
1231
|
+
if (repoBrowserState.activeRoot === 'workspace') {
|
|
1232
|
+
return joinLaunchCwdPath(activeWorkspaceRoot, currentPath);
|
|
1233
|
+
}
|
|
1234
|
+
return normalizeDriveBrowserPath(currentPath) || currentPath.replace(/\\/g, '/');
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
export function repoNodeMapFromTree(root) {
|
|
1238
|
+
const map = new Map();
|
|
1239
|
+
function walk(node) {
|
|
1240
|
+
if (!node || typeof node !== 'object') return;
|
|
1241
|
+
const nodePath = String(node.path || '');
|
|
1242
|
+
map.set(nodePath, node);
|
|
1243
|
+
if (node.type === 'dir' && Array.isArray(node.children)) {
|
|
1244
|
+
for (const child of node.children) walk(child);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
walk(root);
|
|
1248
|
+
return map;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export function repoRawHref(pathValue) {
|
|
1252
|
+
if (repoBrowserState.activeRoot !== 'workspace') {
|
|
1253
|
+
return `${BASE}/api/drives/file?path=${encodeURIComponent(String(pathValue || ''))}`;
|
|
1254
|
+
}
|
|
1255
|
+
return `${BASE}/api/files/${String(pathValue || '').split('/').map((segment) => encodeURIComponent(segment)).join('/')}${currentWorkspaceScopeSuffix()}`;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export function repoIcon(node) {
|
|
1259
|
+
if (!node || typeof node !== 'object') return '📄';
|
|
1260
|
+
if (node.type === 'dir') {
|
|
1261
|
+
if (node.driveType === 'fixed') return '💽';
|
|
1262
|
+
if (node.driveType === 'removable') return '💾';
|
|
1263
|
+
return '📁';
|
|
1264
|
+
}
|
|
1265
|
+
const kind = String(node.previewKind || '').toLowerCase();
|
|
1266
|
+
if (kind === 'image') return '🖼️';
|
|
1267
|
+
if (kind === 'video') return '🎞️';
|
|
1268
|
+
if (kind === 'markdown') return '📝';
|
|
1269
|
+
if (kind === 'code') return '💻';
|
|
1270
|
+
if (kind === 'binary') return '📦';
|
|
1271
|
+
return '📄';
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function updateRepoToolbarUi() {
|
|
1275
|
+
const workspaceRootBtn = document.getElementById('repo-root-workspace-btn');
|
|
1276
|
+
const drivesRootBtn = document.getElementById('repo-root-drives-btn');
|
|
1277
|
+
const sessionRootBtn = document.getElementById('repo-root-session-btn');
|
|
1278
|
+
const listBtn = document.getElementById('repo-view-list-btn');
|
|
1279
|
+
const gridBtn = document.getElementById('repo-view-grid-btn');
|
|
1280
|
+
const hiddenBtn = document.getElementById('repo-toggle-hidden-btn');
|
|
1281
|
+
const heavyBtn = document.getElementById('repo-toggle-heavy-btn');
|
|
1282
|
+
if (!workspaceRootBtn || !drivesRootBtn || !sessionRootBtn || !listBtn || !gridBtn || !hiddenBtn || !heavyBtn) {
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const workspaceRoot = repoBrowserState.activeRoot === 'workspace';
|
|
1286
|
+
const sessionRoot = repoBrowserState.activeRoot === 'session';
|
|
1287
|
+
workspaceRootBtn.classList.toggle('active', workspaceRoot);
|
|
1288
|
+
drivesRootBtn.classList.toggle('active', repoBrowserState.activeRoot === 'drives');
|
|
1289
|
+
sessionRootBtn.classList.toggle('active', sessionRoot);
|
|
1290
|
+
drivesRootBtn.disabled = false;
|
|
1291
|
+
sessionRootBtn.disabled = !repoBrowserState.sessionRootPath;
|
|
1292
|
+
listBtn.classList.toggle('active', repoBrowserState.viewMode === 'list');
|
|
1293
|
+
gridBtn.classList.toggle('active', repoBrowserState.viewMode === 'grid');
|
|
1294
|
+
const hiddenEnabled = workspaceRoot
|
|
1295
|
+
? repoBrowserState.workspaceIncludeHidden
|
|
1296
|
+
: repoBrowserState.drivesIncludeHidden;
|
|
1297
|
+
hiddenBtn.classList.toggle('active', hiddenEnabled);
|
|
1298
|
+
hiddenBtn.textContent = workspaceRoot
|
|
1299
|
+
? `Hidden: ${hiddenEnabled ? 'On' : 'Off'}`
|
|
1300
|
+
: `Hidden/System: ${hiddenEnabled ? 'On' : 'Off'}`;
|
|
1301
|
+
heavyBtn.disabled = !workspaceRoot;
|
|
1302
|
+
heavyBtn.classList.toggle('active', workspaceRoot && repoBrowserState.workspaceIncludeHeavy);
|
|
1303
|
+
heavyBtn.textContent = workspaceRoot
|
|
1304
|
+
? `Heavy: ${repoBrowserState.workspaceIncludeHeavy ? 'On' : 'Off'}`
|
|
1305
|
+
: 'Heavy: n/a';
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
export function renderRepoTreeNode(node) {
|
|
1309
|
+
if (!node || typeof node !== 'object') return '';
|
|
1310
|
+
const nodePath = String(node.path || '');
|
|
1311
|
+
const icon = repoIcon(node);
|
|
1312
|
+
if (node.type === 'dir') {
|
|
1313
|
+
const children = Array.isArray(node.children)
|
|
1314
|
+
? node.children.filter((child) => child?.type === 'dir')
|
|
1315
|
+
: [];
|
|
1316
|
+
const currentPath = String(repoBrowserState.currentPath || '');
|
|
1317
|
+
const isLinuxRoot = nodePath === '/';
|
|
1318
|
+
const isCollapsed = repoBrowserState.collapsedPaths instanceof Set && repoBrowserState.collapsedPaths.has(nodePath);
|
|
1319
|
+
const isExpanded = repoBrowserState.expandedPaths instanceof Set && repoBrowserState.expandedPaths.has(nodePath);
|
|
1320
|
+
const isOpen = !isCollapsed && (nodePath === ''
|
|
1321
|
+
|| isExpanded
|
|
1322
|
+
|| currentPath === nodePath
|
|
1323
|
+
|| (isLinuxRoot
|
|
1324
|
+
? currentPath.startsWith('/')
|
|
1325
|
+
: (currentPath && currentPath.startsWith(`${nodePath}/`))));
|
|
1326
|
+
const openAttr = isOpen ? ' open' : '';
|
|
1327
|
+
const loading = !!node.loadingChildren;
|
|
1328
|
+
const lazyUnloaded = !!node.lazy && !node.childrenLoaded;
|
|
1329
|
+
const childrenHtml = loading
|
|
1330
|
+
? ''
|
|
1331
|
+
: (lazyUnloaded
|
|
1332
|
+
? '<div class="repo-empty">Expand to load entries…</div>'
|
|
1333
|
+
: children.map(renderRepoTreeNode).join(''));
|
|
1334
|
+
return `<details class="repo-tree-node" data-repo-dir-path="${escHtml(nodePath)}"${openAttr}>
|
|
1335
|
+
<summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${escHtml(node.name || '/')}</summary>
|
|
1336
|
+
<div class="repo-tree-children">${childrenHtml}</div>
|
|
1337
|
+
</details>`;
|
|
1338
|
+
}
|
|
1339
|
+
return '';
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
export function updateRepoTreeSelection() {
|
|
1343
|
+
const currentPath = String(repoBrowserState.currentPath || '');
|
|
1344
|
+
const summaries = document.querySelectorAll('#repo-tree .repo-tree-summary[data-repo-open-dir]');
|
|
1345
|
+
summaries.forEach((el) => {
|
|
1346
|
+
const pathValue = String(el.getAttribute('data-repo-open-dir') || '');
|
|
1347
|
+
el.classList.toggle('active', pathValue === currentPath);
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
export function syncRepoTreeToCurrentPath(collapseOthers = false) {
|
|
1352
|
+
const currentPath = String(repoBrowserState.currentPath || '');
|
|
1353
|
+
const treeHost = document.getElementById('repo-tree');
|
|
1354
|
+
if (!treeHost) return;
|
|
1355
|
+
const ancestorPaths = new Set(repoAncestorPaths(currentPath));
|
|
1356
|
+
const details = treeHost.querySelectorAll('details.repo-tree-node[data-repo-dir-path]');
|
|
1357
|
+
details.forEach((el) => {
|
|
1358
|
+
const pathValue = String(el.getAttribute('data-repo-dir-path') || '');
|
|
1359
|
+
const isCollapsed = repoBrowserState.collapsedPaths instanceof Set && repoBrowserState.collapsedPaths.has(pathValue);
|
|
1360
|
+
const isExpanded = repoBrowserState.expandedPaths instanceof Set && repoBrowserState.expandedPaths.has(pathValue);
|
|
1361
|
+
if (!isCollapsed && (ancestorPaths.has(pathValue) || isExpanded)) {
|
|
1362
|
+
el.open = true;
|
|
1363
|
+
} else if (collapseOthers) {
|
|
1364
|
+
el.open = false;
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
// Reveal the active node only when the selection actually moved (or on an
|
|
1368
|
+
// explicit focus): scrolling on every render yanks the pane back while the
|
|
1369
|
+
// user is scrolling it themselves.
|
|
1370
|
+
if (currentPath === lastTreeScrolledPath && !collapseOthers) return;
|
|
1371
|
+
lastTreeScrolledPath = currentPath;
|
|
1372
|
+
const activeEl = treeHost.querySelector('.repo-tree-summary.active, .repo-tree-file.active');
|
|
1373
|
+
if (activeEl) {
|
|
1374
|
+
activeEl.scrollIntoView({ block: 'nearest' });
|
|
1375
|
+
} else {
|
|
1376
|
+
const activeSummary = treeHost.querySelector(`.repo-tree-summary[data-repo-open-dir="${CSS.escape(currentPath)}"]`);
|
|
1377
|
+
if (activeSummary) activeSummary.scrollIntoView({ block: 'nearest' });
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
export function focusRepoTree() {
|
|
1382
|
+
if (repoBrowserState.expandedPaths instanceof Set) {
|
|
1383
|
+
repoBrowserState.expandedPaths.clear();
|
|
1384
|
+
}
|
|
1385
|
+
syncRepoTreeToCurrentPath(true);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
export function renderRepoBreadcrumb() {
|
|
1389
|
+
const rootLabel = repoBrowserState.rootName || 'repo';
|
|
1390
|
+
const pathValue = String(repoBrowserState.currentPath || '');
|
|
1391
|
+
const host = document.getElementById('repo-folder-breadcrumb');
|
|
1392
|
+
if (!host) return;
|
|
1393
|
+
const rootSource = repoBrowserState.activeRoot === 'workspace' ? 'workspace' : 'drives';
|
|
1394
|
+
const parts = pathValue ? pathValue.split('/').filter(Boolean) : [];
|
|
1395
|
+
const chips = [`<button class="repo-crumb" data-repo-nav-dir="">${escHtml(rootLabel)}</button>`];
|
|
1396
|
+
let rolling = '';
|
|
1397
|
+
for (const part of parts) {
|
|
1398
|
+
rolling = rolling ? `${rolling}/${part}` : part;
|
|
1399
|
+
chips.push(`<span>/</span><button class="repo-crumb" data-repo-nav-dir="${escHtml(rolling)}">${escHtml(part)}</button>`);
|
|
1400
|
+
}
|
|
1401
|
+
if (pathValue) {
|
|
1402
|
+
chips.push(
|
|
1403
|
+
`<button class="repo-crumb repo-copy-ref-btn" data-repo-copy-folder="${escHtml(pathValue)}" data-repo-copy-source="${escHtml(rootSource)}" title="Copy folder reference">Copy \`@folder\`</button>`,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
host.innerHTML = chips.join('');
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
export function renderRepoFolder() {
|
|
1410
|
+
const folderHost = document.getElementById('repo-folder');
|
|
1411
|
+
const statusHost = document.getElementById('repo-tree-status');
|
|
1412
|
+
if (!folderHost || !statusHost) return;
|
|
1413
|
+
if (repoBrowserState.loading && !repoBrowserState.tree) {
|
|
1414
|
+
folderHost.innerHTML = '<div class="repo-empty">Loading explorer tree…</div>';
|
|
1415
|
+
statusHost.textContent = 'Loading…';
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (repoBrowserState.error) {
|
|
1419
|
+
folderHost.innerHTML = `<div class="repo-empty">${escHtml(repoBrowserState.error)}</div>`;
|
|
1420
|
+
statusHost.textContent = 'Error';
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
const nodeLabel = repoBrowserState.activeRoot === 'workspace' ? 'nodes' : 'entries';
|
|
1424
|
+
statusHost.textContent = `${repoBrowserState.nodeCount || 0} ${nodeLabel}${repoBrowserState.truncated ? ' (truncated)' : ''}`;
|
|
1425
|
+
const currentPath = String(repoBrowserState.currentPath || '');
|
|
1426
|
+
let node = repoBrowserState.nodeMap.get(currentPath) || null;
|
|
1427
|
+
if (!node || node.type !== 'dir') {
|
|
1428
|
+
node = repoBrowserState.nodeMap.get('') || null;
|
|
1429
|
+
}
|
|
1430
|
+
if (!node || node.type !== 'dir') {
|
|
1431
|
+
folderHost.innerHTML = '<div class="repo-empty">No explorer tree available.</div>';
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
if (node.loadingChildren) {
|
|
1435
|
+
folderHost.innerHTML = '<div class="repo-empty">Loading folder entries…</div>';
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
if (node.lazy && !node.childrenLoaded) {
|
|
1439
|
+
// Self-heal instead of dead-ending: kick off the load for the selected
|
|
1440
|
+
// folder. Converges because ensureRepoChildrenLoaded sets loadingChildren
|
|
1441
|
+
// synchronously and always ends with childrenLoaded, even on error.
|
|
1442
|
+
folderHost.innerHTML = '<div class="repo-empty">Loading folder entries…</div>';
|
|
1443
|
+
if (!node.loadingChildren) void ensureRepoChildrenLoaded(String(node.path || ''));
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const children = Array.isArray(node.children)
|
|
1447
|
+
? node.children.filter((child) => child?.type !== 'dir')
|
|
1448
|
+
: [];
|
|
1449
|
+
if (!children.length) {
|
|
1450
|
+
folderHost.innerHTML = '<div class="repo-empty">This folder has no files.</div>';
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const isGrid = repoBrowserState.viewMode === 'grid';
|
|
1455
|
+
const wrapperClass = isGrid ? 'repo-folder-grid' : 'repo-folder-list';
|
|
1456
|
+
const rows = children.map((child) => {
|
|
1457
|
+
const childPath = String(child.path || '');
|
|
1458
|
+
const rawHref = repoRawHref(childPath);
|
|
1459
|
+
const icon = repoIcon(child);
|
|
1460
|
+
if (isGrid) {
|
|
1461
|
+
const ext = String(child.ext || '').replace('.', '');
|
|
1462
|
+
const isImage = String(child.previewKind || '').toLowerCase() === 'image' || REPO_IMAGE_EXTENSIONS.has(ext.toLowerCase());
|
|
1463
|
+
const thumb = isImage && rawHref
|
|
1464
|
+
? `<img loading="lazy" src="${escHtml(rawHref)}" alt="${escHtml(child.name || '')}">`
|
|
1465
|
+
: `<span>${icon}</span>`;
|
|
1466
|
+
const fileMeta = `${String(child.previewKind || 'file')} · ${formatBytes(child.size || 0)}`;
|
|
1467
|
+
return `<div class="repo-card repo-card-clickable" data-repo-open-file="${escHtml(childPath)}">
|
|
1468
|
+
<div class="repo-card-thumb">${thumb}</div>
|
|
1469
|
+
<div class="repo-card-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
|
|
1470
|
+
<div class="repo-entry-meta">${escHtml(fileMeta)}</div>
|
|
1471
|
+
</div>`;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const mainMeta = `${String(child.previewKind || 'file')} · ${formatBytes(child.size || 0)}`;
|
|
1475
|
+
return `<div class="repo-entry-row repo-entry-row-clickable" data-repo-open-file="${escHtml(childPath)}">
|
|
1476
|
+
<div class="repo-entry-main">
|
|
1477
|
+
<span>${icon}</span>
|
|
1478
|
+
<div style="min-width:0">
|
|
1479
|
+
<div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${escHtml(child.name || childPath)}</div>
|
|
1480
|
+
<div class="repo-entry-meta">${escHtml(mainMeta)}</div>
|
|
1481
|
+
</div>
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>`;
|
|
1484
|
+
}).join('');
|
|
1485
|
+
|
|
1486
|
+
const savedScrollTop = folderHost.scrollTop;
|
|
1487
|
+
folderHost.innerHTML = `<div class="${wrapperClass}">${rows}</div>`;
|
|
1488
|
+
folderHost.scrollTop = savedScrollTop;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
export function renderRepoTree() {
|
|
1492
|
+
const treeHost = document.getElementById('repo-tree');
|
|
1493
|
+
if (!treeHost) return;
|
|
1494
|
+
if (repoBrowserState.loading && !repoBrowserState.tree) {
|
|
1495
|
+
treeHost.innerHTML = '<div class="repo-empty">Loading tree…</div>';
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (repoBrowserState.error) {
|
|
1499
|
+
treeHost.innerHTML = `<div class="repo-empty">${escHtml(repoBrowserState.error)}</div>`;
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const root = repoBrowserState.tree;
|
|
1503
|
+
if (!root) {
|
|
1504
|
+
treeHost.innerHTML = '<div class="repo-empty">Tree unavailable.</div>';
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
// The rebuild resets the pane's scroll to 0; put it back so re-renders
|
|
1508
|
+
// (child loads, refreshes) don't rubber-band a scroll in progress.
|
|
1509
|
+
const savedScrollTop = treeHost.scrollTop;
|
|
1510
|
+
treeHost.innerHTML = renderRepoTreeNode(root);
|
|
1511
|
+
updateRepoTreeSelection();
|
|
1512
|
+
treeHost.scrollTop = savedScrollTop;
|
|
1513
|
+
syncRepoTreeToCurrentPath();
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
export function renderRepoBrowser() {
|
|
1517
|
+
if (repoBrowserRenderSuspended > 0) {
|
|
1518
|
+
repoBrowserRenderDirty = true;
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const title = document.querySelector('.repo-browser-title');
|
|
1522
|
+
if (title) {
|
|
1523
|
+
title.textContent = repoBrowserState.activeRoot === 'workspace'
|
|
1524
|
+
? 'Repository Browser'
|
|
1525
|
+
: (repoBrowserState.activeRoot === 'session' ? 'Session Browser' : 'Drives Browser');
|
|
1526
|
+
}
|
|
1527
|
+
updateRepoToolbarUi();
|
|
1528
|
+
renderRepoBreadcrumb();
|
|
1529
|
+
renderRepoTree();
|
|
1530
|
+
renderRepoFolder();
|
|
1531
|
+
updateRepoCwdPickBar();
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
export async function loadRepoBrowserTree() {
|
|
1535
|
+
if (repoBrowserState.loading) {
|
|
1536
|
+
repoBrowserReloadQueued = true;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
repoBrowserReloadQueued = false;
|
|
1540
|
+
repoBrowserState.loading = true;
|
|
1541
|
+
repoBrowserState.loadingPath = '';
|
|
1542
|
+
repoBrowserState.error = '';
|
|
1543
|
+
renderRepoBrowser();
|
|
1544
|
+
|
|
1545
|
+
const workspaceRoot = repoBrowserState.activeRoot === 'workspace';
|
|
1546
|
+
const sessionRoot = repoBrowserState.activeRoot === 'session';
|
|
1547
|
+
const requestedConversationId = workspaceRoot ? currentConversationId() : '';
|
|
1548
|
+
const payload = workspaceRoot
|
|
1549
|
+
? await loadRepoTree(repoBrowserState.workspaceIncludeHidden, repoBrowserState.workspaceIncludeHeavy, requestedConversationId)
|
|
1550
|
+
: (sessionRoot
|
|
1551
|
+
? await loadSessionRootTree(normalizeDriveBrowserPath(repoBrowserState.sessionRootPath), repoBrowserState.drivesIncludeHidden)
|
|
1552
|
+
: await loadDrivesRoots());
|
|
1553
|
+
if (workspaceRoot && requestedConversationId !== currentConversationId()) {
|
|
1554
|
+
repoBrowserState.loading = false;
|
|
1555
|
+
repoBrowserReloadQueued = true;
|
|
1556
|
+
flushQueuedRepoBrowserReload();
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
const rootNode = payload?.root || payload?.node || null;
|
|
1560
|
+
if (!payload || payload.error || !rootNode) {
|
|
1561
|
+
repoBrowserState.loading = false;
|
|
1562
|
+
repoBrowserState.error = payload?.error || (workspaceRoot ? 'Failed to load repository tree.' : (sessionRoot ? 'Failed to load session tree.' : 'Failed to load drives.'));
|
|
1563
|
+
pendingRepoBrowserRestore = null;
|
|
1564
|
+
renderRepoBrowser();
|
|
1565
|
+
flushQueuedRepoBrowserReload();
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
repoBrowserState.loading = false;
|
|
1570
|
+
repoBrowserState.rootName = String(payload.rootName || (workspaceRoot ? 'repo' : (sessionRoot ? repoBrowserState.sessionRootName || 'Session' : 'Drives')));
|
|
1571
|
+
repoBrowserState.tree = rootNode;
|
|
1572
|
+
repoBrowserState.nodeMap = repoNodeMapFromTree(rootNode);
|
|
1573
|
+
repoBrowserState.truncated = !!payload.truncated;
|
|
1574
|
+
repoBrowserState.nodeCount = Number(payload.nodeCount || repoBrowserState.nodeMap.size || 0);
|
|
1575
|
+
repoBrowserState.maxNodes = Number(payload.maxNodes || repoBrowserState.nodeMap.size || 0);
|
|
1576
|
+
if (sessionRoot) {
|
|
1577
|
+
repoBrowserState.currentPath = String(rootNode.path || repoBrowserState.sessionRootPath || '');
|
|
1578
|
+
if (rootNode && typeof rootNode === 'object') {
|
|
1579
|
+
rootNode.childrenLoaded = true;
|
|
1580
|
+
rootNode.lazy = false;
|
|
1581
|
+
}
|
|
1582
|
+
} else if (!workspaceRoot && rootNode?.path && repoBrowserState.nodeMap.has(String(rootNode.path))) {
|
|
1583
|
+
repoBrowserState.currentPath = String(rootNode.path);
|
|
1584
|
+
} else if (!repoBrowserState.nodeMap.has(repoBrowserState.currentPath)) {
|
|
1585
|
+
repoBrowserState.currentPath = '';
|
|
1586
|
+
}
|
|
1587
|
+
if (repoBrowserState.expandedPaths instanceof Set) {
|
|
1588
|
+
const selectedPath = String(repoBrowserState.currentPath || '');
|
|
1589
|
+
if (selectedPath) repoBrowserState.expandedPaths.add(selectedPath);
|
|
1590
|
+
}
|
|
1591
|
+
renderRepoBrowser();
|
|
1592
|
+
// Read the queue flag before flushing: flushQueuedRepoBrowserReload re-enters
|
|
1593
|
+
// this function synchronously. When another load is already queued, leave the
|
|
1594
|
+
// restore parked so that load performs it against the tree it actually
|
|
1595
|
+
// fetched — that is what makes a rapid double-toggle safe.
|
|
1596
|
+
const restoreNow = repoBrowserReloadQueued ? null : takePendingRepoBrowserRestore();
|
|
1597
|
+
flushQueuedRepoBrowserReload();
|
|
1598
|
+
if (restoreNow) void applyRepoBrowserRestore(restoreNow);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const repoChildrenLoadsInFlight = new Map();
|
|
1602
|
+
|
|
1603
|
+
export async function ensureRepoChildrenLoaded(pathValue) {
|
|
1604
|
+
if (pathValue === null || pathValue === undefined) return false;
|
|
1605
|
+
const normalizedPath = normalizeRepoPath(pathValue);
|
|
1606
|
+
const nodePath = normalizedPath || '';
|
|
1607
|
+
if (!nodePath && repoBrowserState.activeRoot !== 'workspace') return false;
|
|
1608
|
+
|
|
1609
|
+
const node = repoBrowserState.nodeMap.get(nodePath);
|
|
1610
|
+
if (!node || node.type !== 'dir') return false;
|
|
1611
|
+
if (node.childrenLoaded) return true;
|
|
1612
|
+
// Concurrent callers (folder-pane self-heal racing the reopen restore walk)
|
|
1613
|
+
// must share one fetch: returning false here would make the restore silently
|
|
1614
|
+
// skip the branch and drop its descendants.
|
|
1615
|
+
const inFlight = repoChildrenLoadsInFlight.get(nodePath);
|
|
1616
|
+
if (inFlight) return inFlight;
|
|
1617
|
+
|
|
1618
|
+
const load = loadRepoChildrenIntoNode(nodePath, node);
|
|
1619
|
+
repoChildrenLoadsInFlight.set(nodePath, load);
|
|
1620
|
+
try {
|
|
1621
|
+
return await load;
|
|
1622
|
+
} finally {
|
|
1623
|
+
repoChildrenLoadsInFlight.delete(nodePath);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
async function loadRepoChildrenIntoNode(nodePath, node) {
|
|
1628
|
+
node.loadingChildren = true;
|
|
1629
|
+
repoBrowserState.loadingPath = nodePath;
|
|
1630
|
+
renderRepoBrowser();
|
|
1631
|
+
const treeAtRequest = repoBrowserState.tree;
|
|
1632
|
+
|
|
1633
|
+
// A rejection must not strand loadingChildren=true: the folder pane would
|
|
1634
|
+
// show "Loading…" forever and the self-heal guard would never refire. Treat
|
|
1635
|
+
// it exactly like an error payload (empty children, readError) below.
|
|
1636
|
+
let payload = null;
|
|
1637
|
+
try {
|
|
1638
|
+
payload = repoBrowserState.activeRoot === 'workspace'
|
|
1639
|
+
? await loadRepoChildren(
|
|
1640
|
+
nodePath,
|
|
1641
|
+
repoBrowserState.workspaceIncludeHidden,
|
|
1642
|
+
repoBrowserState.workspaceIncludeHeavy,
|
|
1643
|
+
currentConversationId(),
|
|
1644
|
+
)
|
|
1645
|
+
: await loadDriveChildren(nodePath, repoBrowserState.drivesIncludeHidden);
|
|
1646
|
+
} catch {
|
|
1647
|
+
payload = null;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
node.loadingChildren = false;
|
|
1651
|
+
repoBrowserState.loadingPath = '';
|
|
1652
|
+
// The tree can be swapped out mid-fetch by a reload; writing onto the
|
|
1653
|
+
// orphaned node object would silently drop the children. Attach to the node
|
|
1654
|
+
// that now lives at the same path, or bail if the path is gone.
|
|
1655
|
+
const target = repoBrowserState.tree === treeAtRequest
|
|
1656
|
+
? node
|
|
1657
|
+
: repoBrowserState.nodeMap.get(nodePath) || null;
|
|
1658
|
+
if (!target || target.type !== 'dir') {
|
|
1659
|
+
renderRepoBrowser();
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
target.loadingChildren = false;
|
|
1663
|
+
if (!payload || payload.error || !payload.node || !Array.isArray(payload.node.children)) {
|
|
1664
|
+
target.children = [];
|
|
1665
|
+
target.childrenLoaded = true;
|
|
1666
|
+
target.readError = true;
|
|
1667
|
+
renderRepoBrowser();
|
|
1668
|
+
return false;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
target.children = payload.node.children;
|
|
1672
|
+
target.childrenLoaded = true;
|
|
1673
|
+
target.lazy = false;
|
|
1674
|
+
target.readError = !!payload.node.readError;
|
|
1675
|
+
repoBrowserState.nodeMap = repoNodeMapFromTree(repoBrowserState.tree);
|
|
1676
|
+
repoBrowserState.nodeCount = repoBrowserState.nodeMap.size;
|
|
1677
|
+
renderRepoBrowser();
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Re-open, on the freshly fetched tree, whatever was open before a refresh.
|
|
1683
|
+
*
|
|
1684
|
+
* The chain is walked sequentially on purpose: ensureRepoChildrenLoaded rebuilds
|
|
1685
|
+
* nodeMap on every success, and a child directory only becomes reachable once
|
|
1686
|
+
* its parent's children have landed.
|
|
1687
|
+
*/
|
|
1688
|
+
async function applyRepoBrowserRestore({ path: keepPath = '', seq = 0 } = {}) {
|
|
1689
|
+
const expandedPaths = repoBrowserState.expandedPaths instanceof Set
|
|
1690
|
+
? [...repoBrowserState.expandedPaths]
|
|
1691
|
+
: [];
|
|
1692
|
+
const plan = planRepoRehydration({ currentPath: keepPath, expandedPaths });
|
|
1693
|
+
const pathAtStart = String(repoBrowserState.currentPath || '');
|
|
1694
|
+
|
|
1695
|
+
await withSuspendedRepoRender(async () => {
|
|
1696
|
+
for (const nodePath of plan) {
|
|
1697
|
+
if (seq !== repoBrowserRefreshSeq) return;
|
|
1698
|
+
if (!nodePath) continue;
|
|
1699
|
+
// A directory that vanished with the filter change is simply unknown to
|
|
1700
|
+
// the new nodeMap, and this returns false without touching the network.
|
|
1701
|
+
await ensureRepoChildrenLoaded(nodePath);
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
if (seq !== repoBrowserRefreshSeq) return;
|
|
1706
|
+
if (String(repoBrowserState.currentPath || '') !== pathAtStart) {
|
|
1707
|
+
renderRepoBrowser();
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
const resolvedPath = deepestExistingAncestor(keepPath, (candidate) => {
|
|
1711
|
+
const node = repoBrowserState.nodeMap.get(candidate);
|
|
1712
|
+
return !!node && node.type === 'dir';
|
|
1713
|
+
});
|
|
1714
|
+
if (resolvedPath) await setRepoCurrentPath(resolvedPath);
|
|
1715
|
+
else renderRepoBrowser();
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
export async function ensureDriveChildrenLoaded(pathValue) {
|
|
1719
|
+
return ensureRepoChildrenLoaded(pathValue);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
export async function ensureWorkspaceChildrenLoaded(pathValue) {
|
|
1723
|
+
return ensureRepoChildrenLoaded(pathValue);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
export function setRepoBrowserRoot(root) {
|
|
1727
|
+
const nextRoot = String(root || '').trim().toLowerCase();
|
|
1728
|
+
if (nextRoot !== 'workspace' && nextRoot !== 'drives' && nextRoot !== 'session') return;
|
|
1729
|
+
if (nextRoot === 'session' && !repoBrowserState.sessionRootPath) return;
|
|
1730
|
+
if (repoBrowserState.activeRoot === nextRoot) return;
|
|
1731
|
+
repoBrowserState.activeRoot = nextRoot;
|
|
1732
|
+
// A root switch invalidates any parked restore: the saved path belongs to the
|
|
1733
|
+
// tree we are leaving. The Set wipes below are correct here for the same reason.
|
|
1734
|
+
pendingRepoBrowserRestore = null;
|
|
1735
|
+
setRepoBrowserState({
|
|
1736
|
+
tree: null,
|
|
1737
|
+
nodeMap: new Map(),
|
|
1738
|
+
expandedPaths: new Set(),
|
|
1739
|
+
collapsedPaths: new Set(),
|
|
1740
|
+
currentPath: '',
|
|
1741
|
+
truncated: false,
|
|
1742
|
+
nodeCount: 0,
|
|
1743
|
+
maxNodes: 0,
|
|
1744
|
+
loadingPath: '',
|
|
1745
|
+
error: '',
|
|
1746
|
+
});
|
|
1747
|
+
renderRepoBrowser();
|
|
1748
|
+
if (repoBrowserState.open) {
|
|
1749
|
+
void loadRepoBrowserTree();
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
export function setRepoBrowserSessionInfo(sessionRootPath, sessionRootName = '') {
|
|
1754
|
+
const nextPath = normalizeDriveBrowserPath(sessionRootPath);
|
|
1755
|
+
const nextName = String(sessionRootName || '').trim() || 'Session';
|
|
1756
|
+
const pathChanged = repoBrowserState.sessionRootPath !== nextPath;
|
|
1757
|
+
repoBrowserState.sessionRootPath = nextPath;
|
|
1758
|
+
repoBrowserState.sessionRootName = nextName;
|
|
1759
|
+
const sessionRootActive = repoBrowserState.activeRoot === 'session';
|
|
1760
|
+
if (pathChanged && sessionRootActive) {
|
|
1761
|
+
pendingRepoBrowserRestore = null;
|
|
1762
|
+
setRepoBrowserState({
|
|
1763
|
+
tree: null,
|
|
1764
|
+
nodeMap: new Map(),
|
|
1765
|
+
expandedPaths: new Set(),
|
|
1766
|
+
collapsedPaths: new Set(),
|
|
1767
|
+
currentPath: '',
|
|
1768
|
+
truncated: false,
|
|
1769
|
+
nodeCount: 0,
|
|
1770
|
+
maxNodes: 0,
|
|
1771
|
+
loadingPath: '',
|
|
1772
|
+
error: '',
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
if (!nextPath && sessionRootActive) {
|
|
1776
|
+
repoBrowserState.activeRoot = 'workspace';
|
|
1777
|
+
if (repoBrowserState.open) {
|
|
1778
|
+
void loadRepoBrowserTree();
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
renderRepoBrowser();
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (repoBrowserState.open) {
|
|
1785
|
+
if (sessionRootActive && pathChanged) {
|
|
1786
|
+
void loadRepoBrowserTree();
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
renderRepoBrowser();
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
if (sessionRootActive) {
|
|
1793
|
+
renderRepoBrowser();
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Non-null while the browser is acting as a CWD picker for the New chat /
|
|
1798
|
+
// Change CWD modals. Cleared on every close so a later plain open never
|
|
1799
|
+
// resurrects the pick bar.
|
|
1800
|
+
let repoBrowserCwdPickHandler = null;
|
|
1801
|
+
|
|
1802
|
+
function repoBrowserCwdPickModeActive() {
|
|
1803
|
+
return typeof repoBrowserCwdPickHandler === 'function';
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* The picked path, as opposed to the launch path: `getRepoBrowserLaunchCwdPath`
|
|
1808
|
+
* falls back to the conversation's workspace root when nothing is selected,
|
|
1809
|
+
* which is right for the known-CWD list but wrong for a picker — on the drives
|
|
1810
|
+
* root it would offer (and enable confirming) a path from a different tree.
|
|
1811
|
+
* Only the workspace root treats an empty selection as a pick, because there
|
|
1812
|
+
* the visible tree root IS that path.
|
|
1813
|
+
*/
|
|
1814
|
+
function getRepoBrowserPickedCwdPath() {
|
|
1815
|
+
const currentPath = String(repoBrowserState.currentPath || '').trim();
|
|
1816
|
+
if (!currentPath && repoBrowserState.activeRoot !== 'workspace') return '';
|
|
1817
|
+
return String(getRepoBrowserLaunchCwdPath() || '').trim();
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function updateRepoCwdPickBar() {
|
|
1821
|
+
const bar = document.getElementById('repo-cwd-pick-bar');
|
|
1822
|
+
if (!bar) return;
|
|
1823
|
+
const active = repoBrowserCwdPickModeActive();
|
|
1824
|
+
bar.hidden = !active;
|
|
1825
|
+
if (!active) return;
|
|
1826
|
+
const pathEl = document.getElementById('repo-cwd-pick-path');
|
|
1827
|
+
const confirmBtn = document.getElementById('repo-cwd-pick-confirm');
|
|
1828
|
+
const pickedPath = getRepoBrowserPickedCwdPath();
|
|
1829
|
+
if (pathEl) pathEl.textContent = pickedPath || 'Select a folder…';
|
|
1830
|
+
if (confirmBtn) confirmBtn.disabled = !pickedPath;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Open the browser as a folder picker: the pick bar appears, the modal stacks
|
|
1835
|
+
* above the summary/new-chat modals, and the global (drives) tree is the
|
|
1836
|
+
* default root — the root tabs stay live, so workspace/session picks work too.
|
|
1837
|
+
*/
|
|
1838
|
+
export function openRepoBrowserForCwdPick(onPick) {
|
|
1839
|
+
repoBrowserCwdPickHandler = typeof onPick === 'function' ? onPick : null;
|
|
1840
|
+
const modal = document.getElementById('repo-browser-modal');
|
|
1841
|
+
modal.classList.add('cwd-pick-mode');
|
|
1842
|
+
if (repoBrowserState.activeRoot !== 'drives') setRepoBrowserRoot('drives');
|
|
1843
|
+
openRepoBrowser();
|
|
1844
|
+
updateRepoCwdPickBar();
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
export function confirmRepoBrowserCwdPick() {
|
|
1848
|
+
const handler = repoBrowserCwdPickHandler;
|
|
1849
|
+
if (!handler) return;
|
|
1850
|
+
const pickedPath = getRepoBrowserPickedCwdPath();
|
|
1851
|
+
if (!pickedPath) return;
|
|
1852
|
+
closeRepoBrowser();
|
|
1853
|
+
handler(pickedPath);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
export function openRepoBrowser() {
|
|
1857
|
+
const modal = document.getElementById('repo-browser-modal');
|
|
1858
|
+
modal.classList.add('visible');
|
|
1859
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
1860
|
+
repoBrowserState.open = true;
|
|
1861
|
+
if (repoBrowserState.activeRoot === 'workspace') {
|
|
1862
|
+
// Reopening refetches the lazy workspace root, which would otherwise strand
|
|
1863
|
+
// the surviving expansion/selection on unloaded placeholder nodes. Park a
|
|
1864
|
+
// restore exactly like Refresh does — but keep the previous tree on screen
|
|
1865
|
+
// while the refetch is in flight, so reopening never flashes a loader.
|
|
1866
|
+
// An already-parked restore (an in-flight Refresh wiped currentPath to '')
|
|
1867
|
+
// still holds the real selection; parking over it would lose the path.
|
|
1868
|
+
if (!pendingRepoBrowserRestore) parkRepoBrowserRestore();
|
|
1869
|
+
void loadRepoBrowserTree();
|
|
1870
|
+
} else if (!repoBrowserState.tree) {
|
|
1871
|
+
void loadRepoBrowserTree();
|
|
1872
|
+
} else {
|
|
1873
|
+
renderRepoBrowser();
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
export function closeRepoBrowser() {
|
|
1878
|
+
const modal = document.getElementById('repo-browser-modal');
|
|
1879
|
+
modal.classList.remove('visible');
|
|
1880
|
+
modal.classList.remove('cwd-pick-mode');
|
|
1881
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
1882
|
+
repoBrowserState.open = false;
|
|
1883
|
+
repoBrowserCwdPickHandler = null;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
export function refreshRepoBrowser() {
|
|
1887
|
+
// expandedPaths / collapsedPaths deliberately survive: a refresh refetches the
|
|
1888
|
+
// same root, so the user's expansion is still meaningful. applyRepoBrowserRestore
|
|
1889
|
+
// re-walks it once the new tree arrives.
|
|
1890
|
+
parkRepoBrowserRestore();
|
|
1891
|
+
setRepoBrowserState({
|
|
1892
|
+
tree: null,
|
|
1893
|
+
nodeMap: new Map(),
|
|
1894
|
+
currentPath: '',
|
|
1895
|
+
loadingPath: '',
|
|
1896
|
+
error: '',
|
|
1897
|
+
});
|
|
1898
|
+
void loadRepoBrowserTree();
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
export function refreshRepoBrowserIfWorkspaceOpen() {
|
|
1902
|
+
if (!repoBrowserState.open || repoBrowserState.activeRoot !== 'workspace') return;
|
|
1903
|
+
refreshRepoBrowser();
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
export function resetWorkspaceRepoBrowserForRootChange() {
|
|
1907
|
+
// A CWD change repoints the workspace root, so every cached path (tree,
|
|
1908
|
+
// selection, expansion) belongs to a directory the browser no longer shows.
|
|
1909
|
+
// Dropping them is what stops the explorer from rendering the old CWD after a
|
|
1910
|
+
// relaunch.
|
|
1911
|
+
if (repoBrowserState.activeRoot !== 'workspace') return;
|
|
1912
|
+
pendingRepoBrowserRestore = null;
|
|
1913
|
+
repoBrowserState.expandedPaths = new Set();
|
|
1914
|
+
repoBrowserState.collapsedPaths = new Set();
|
|
1915
|
+
setRepoBrowserState({
|
|
1916
|
+
tree: null,
|
|
1917
|
+
nodeMap: new Map(),
|
|
1918
|
+
currentPath: '',
|
|
1919
|
+
loadingPath: '',
|
|
1920
|
+
error: '',
|
|
1921
|
+
rootName: 'repo',
|
|
1922
|
+
});
|
|
1923
|
+
if (repoBrowserState.open) {
|
|
1924
|
+
void loadRepoBrowserTree();
|
|
1925
|
+
} else {
|
|
1926
|
+
renderRepoBrowser();
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
export function setRepoBrowserViewMode(mode) {
|
|
1931
|
+
const value = String(mode || '').toLowerCase();
|
|
1932
|
+
if (value !== 'list' && value !== 'grid') return;
|
|
1933
|
+
repoBrowserState.viewMode = value;
|
|
1934
|
+
renderRepoFolder();
|
|
1935
|
+
updateRepoToolbarUi();
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
export function toggleRepoBrowserHidden() {
|
|
1939
|
+
const nextValue = repoBrowserState.activeRoot === 'workspace'
|
|
1940
|
+
? !repoBrowserState.workspaceIncludeHidden
|
|
1941
|
+
: !repoBrowserState.drivesIncludeHidden;
|
|
1942
|
+
if (repoBrowserState.activeRoot === 'workspace') {
|
|
1943
|
+
repoBrowserState.workspaceIncludeHidden = nextValue;
|
|
1944
|
+
} else {
|
|
1945
|
+
repoBrowserState.drivesIncludeHidden = nextValue;
|
|
1946
|
+
}
|
|
1947
|
+
writeRepoBrowserHiddenPreference(repoBrowserState.activeRoot, nextValue);
|
|
1948
|
+
refreshRepoBrowser();
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
export function toggleRepoBrowserHeavy() {
|
|
1952
|
+
if (repoBrowserState.activeRoot !== 'workspace') return;
|
|
1953
|
+
repoBrowserState.workspaceIncludeHeavy = !repoBrowserState.workspaceIncludeHeavy;
|
|
1954
|
+
writeRepoBrowserHeavyPreference(repoBrowserState.workspaceIncludeHeavy);
|
|
1955
|
+
refreshRepoBrowser();
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
export async function setRepoCurrentPath(pathValue) {
|
|
1959
|
+
const rawValue = String(pathValue || '').replace(/\\/g, '/').trim();
|
|
1960
|
+
const normalized = normalizeRepoPath(rawValue);
|
|
1961
|
+
const targetPath = repoBrowserState.nodeMap.has(rawValue)
|
|
1962
|
+
? rawValue
|
|
1963
|
+
: (normalized || '');
|
|
1964
|
+
const node = repoBrowserState.nodeMap.get(targetPath);
|
|
1965
|
+
if (!node || node.type !== 'dir') return;
|
|
1966
|
+
if (repoBrowserState.expandedPaths instanceof Set) {
|
|
1967
|
+
for (const ancestor of repoAncestorPaths(targetPath)) {
|
|
1968
|
+
if (ancestor) repoBrowserState.expandedPaths.add(ancestor);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
if (repoBrowserState.collapsedPaths instanceof Set) {
|
|
1972
|
+
repoBrowserState.collapsedPaths.delete(targetPath);
|
|
1973
|
+
}
|
|
1974
|
+
repoBrowserState.currentPath = targetPath;
|
|
1975
|
+
await ensureRepoChildrenLoaded(targetPath);
|
|
1976
|
+
renderRepoBreadcrumb();
|
|
1977
|
+
renderRepoFolder();
|
|
1978
|
+
updateRepoTreeSelection();
|
|
1979
|
+
syncRepoTreeToCurrentPath();
|
|
1980
|
+
updateRepoCwdPickBar();
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
document.addEventListener('click', (event) => {
|
|
1984
|
+
const anchor = eventClosest(event, 'a.workspace-file-link[data-workspace-path]');
|
|
1985
|
+
if (!anchor) return;
|
|
1986
|
+
event.preventDefault();
|
|
1987
|
+
void openWorkspaceFilePreview(anchor.dataset.workspacePath || '');
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
document.getElementById('file-preview-modal').addEventListener('click', (event) => {
|
|
1991
|
+
if (event.target.id === 'file-preview-modal') closeFilePreview();
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
document.getElementById('file-preview-body').addEventListener('click', (event) => {
|
|
1995
|
+
const anchor = eventClosest(event, '.file-preview-markdown a[href]');
|
|
1996
|
+
if (!anchor) return;
|
|
1997
|
+
const target = resolveFilePreviewLink(anchor.getAttribute('href'), filePreviewState.path);
|
|
1998
|
+
if (target.kind === 'external') {
|
|
1999
|
+
event.preventDefault();
|
|
2000
|
+
openExternalNavigation(anchor.href, (url) => {
|
|
2001
|
+
window.dispatchEvent(new CustomEvent('copilot:external-link-fallback', { detail: { url } }));
|
|
2002
|
+
});
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
event.preventDefault();
|
|
2006
|
+
void openFilePreviewLink(anchor);
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
document.getElementById('summary-modal').addEventListener('click', (event) => {
|
|
2010
|
+
if (event.target.id === 'summary-modal') window.closeSummaryModal?.();
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
document.getElementById('file-preview-title').addEventListener('click', async (event) => {
|
|
2014
|
+
const target = event.currentTarget;
|
|
2015
|
+
const token = String(target?.getAttribute('data-copy-reference') || '').trim();
|
|
2016
|
+
if (!token) return;
|
|
2017
|
+
event.preventDefault();
|
|
2018
|
+
const wrapped = `\`${token}\``;
|
|
2019
|
+
try {
|
|
2020
|
+
await copyTextToClipboard(wrapped);
|
|
2021
|
+
} catch {}
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
document.getElementById('repo-browser-modal').addEventListener('click', (event) => {
|
|
2025
|
+
if (event.target.id === 'repo-browser-modal') closeRepoBrowser();
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
document.getElementById('repo-tree').addEventListener('click', (event) => {
|
|
2029
|
+
const fileButton = eventClosest(event, '[data-repo-open-file]');
|
|
2030
|
+
if (fileButton) {
|
|
2031
|
+
event.preventDefault();
|
|
2032
|
+
// In pick mode the preview modal (z 11000) would open UNDER the raised
|
|
2033
|
+
// browser (z 12400) — an invisible modal that then swallows Escape. A CWD
|
|
2034
|
+
// picker is about folders; file clicks are inert there.
|
|
2035
|
+
if (!repoBrowserCwdPickModeActive()) {
|
|
2036
|
+
void openWorkspaceFilePreviewFromRepo(fileButton.getAttribute('data-repo-open-file') || '');
|
|
2037
|
+
}
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
const dirSummary = eventClosest(event, '[data-repo-open-dir]');
|
|
2041
|
+
if (dirSummary) {
|
|
2042
|
+
event.preventDefault();
|
|
2043
|
+
const dirPath = dirSummary.getAttribute('data-repo-open-dir') || '';
|
|
2044
|
+
const normalizedDirPath = normalizeRepoPath(dirPath);
|
|
2045
|
+
const targetPath = repoBrowserState.nodeMap.has(dirPath) ? dirPath : (normalizedDirPath || '');
|
|
2046
|
+
const node = repoBrowserState.nodeMap.get(targetPath);
|
|
2047
|
+
const isDir = !!node && node.type === 'dir';
|
|
2048
|
+
const isCurrent = String(repoBrowserState.currentPath || '') === targetPath;
|
|
2049
|
+
const isCollapsed = repoBrowserState.collapsedPaths instanceof Set && repoBrowserState.collapsedPaths.has(targetPath);
|
|
2050
|
+
// A lazy dir whose children never loaded is showing a placeholder, and the
|
|
2051
|
+
// click means "load it" — never "collapse it". Only a loaded current dir
|
|
2052
|
+
// collapse-toggles.
|
|
2053
|
+
const isLoadedDir = isDir && !(node.lazy && !node.childrenLoaded);
|
|
2054
|
+
if (isLoadedDir && isCurrent && !isCollapsed && targetPath) {
|
|
2055
|
+
if (repoBrowserState.expandedPaths instanceof Set) {
|
|
2056
|
+
repoBrowserState.expandedPaths.delete(targetPath);
|
|
2057
|
+
}
|
|
2058
|
+
repoBrowserState.collapsedPaths.add(targetPath);
|
|
2059
|
+
renderRepoTree();
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (repoBrowserState.expandedPaths instanceof Set && targetPath) {
|
|
2063
|
+
repoBrowserState.expandedPaths.add(targetPath);
|
|
2064
|
+
}
|
|
2065
|
+
if (repoBrowserState.collapsedPaths instanceof Set) {
|
|
2066
|
+
repoBrowserState.collapsedPaths.delete(targetPath);
|
|
2067
|
+
}
|
|
2068
|
+
void setRepoCurrentPath(dirPath);
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
document.getElementById('repo-folder').addEventListener('click', (event) => {
|
|
2073
|
+
const navDir = eventClosest(event, '[data-repo-nav-dir]');
|
|
2074
|
+
if (navDir) {
|
|
2075
|
+
event.preventDefault();
|
|
2076
|
+
void setRepoCurrentPath(navDir.getAttribute('data-repo-nav-dir') || '');
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const openFile = eventClosest(event, '[data-repo-open-file]');
|
|
2080
|
+
if (openFile) {
|
|
2081
|
+
event.preventDefault();
|
|
2082
|
+
// Same pick-mode guard as the tree pane: no invisible preview under the picker.
|
|
2083
|
+
if (!repoBrowserCwdPickModeActive()) {
|
|
2084
|
+
void openWorkspaceFilePreviewFromRepo(openFile.getAttribute('data-repo-open-file') || '');
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
document.getElementById('repo-folder-breadcrumb').addEventListener('click', (event) => {
|
|
2090
|
+
const copyFolder = eventClosest(event, '[data-repo-copy-folder]');
|
|
2091
|
+
if (copyFolder) {
|
|
2092
|
+
event.preventDefault();
|
|
2093
|
+
const folderPath = copyFolder.getAttribute('data-repo-copy-folder') || '';
|
|
2094
|
+
const source = copyFolder.getAttribute('data-repo-copy-source') || repoBrowserState.activeRoot;
|
|
2095
|
+
void copyReferenceTokenToClipboard('folder', folderPath, source);
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
const navDir = eventClosest(event, '[data-repo-nav-dir]');
|
|
2099
|
+
if (!navDir) return;
|
|
2100
|
+
event.preventDefault();
|
|
2101
|
+
void setRepoCurrentPath(navDir.getAttribute('data-repo-nav-dir') || '');
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
document.addEventListener('keydown', (event) => {
|
|
2105
|
+
if (event.key !== 'Escape') return;
|
|
2106
|
+
const modal = document.getElementById('file-preview-modal');
|
|
2107
|
+
const repoModal = document.getElementById('repo-browser-modal');
|
|
2108
|
+
const summaryModal = document.getElementById('summary-modal');
|
|
2109
|
+
if (modal.classList.contains('visible')) closeFilePreview();
|
|
2110
|
+
else if (repoModal.classList.contains('visible')) closeRepoBrowser();
|
|
2111
|
+
else if (summaryModal.classList.contains('visible')) window.closeSummaryModal?.();
|
|
2112
|
+
});
|