@oar-sh/oar 0.9.9 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
getConversationCurrentWorkspaceRootPath,
|
|
15
15
|
IS_SHARED_VIEW,
|
|
16
16
|
conversations,
|
|
17
|
+
openSummaryModal,
|
|
18
|
+
closeSummaryModal,
|
|
19
|
+
renderSummaryModalContent,
|
|
20
|
+
setSummaryModalLoading,
|
|
17
21
|
} from './store.js';
|
|
18
22
|
import { openAnnotateEditor } from './annotate-editor.js';
|
|
19
23
|
import {
|
|
@@ -25,7 +29,21 @@ import {
|
|
|
25
29
|
loadDrivesRoots,
|
|
26
30
|
loadDriveChildren,
|
|
27
31
|
loadSessionRootTree,
|
|
32
|
+
createExplorerFolder,
|
|
33
|
+
renameExplorerEntry,
|
|
34
|
+
deleteExplorerEntry,
|
|
28
35
|
} from './api-client.js';
|
|
36
|
+
import { MENU_SEPARATOR, bindLongPress, openContextMenu } from './context-menu.mjs';
|
|
37
|
+
import {
|
|
38
|
+
childPathOf,
|
|
39
|
+
descendantsOf,
|
|
40
|
+
dropPathsBelow,
|
|
41
|
+
entryNameOf,
|
|
42
|
+
isSameOrBelow,
|
|
43
|
+
parentPathOf,
|
|
44
|
+
rewritePathPrefix,
|
|
45
|
+
rewritePathSet,
|
|
46
|
+
} from './explorer-edit-state.mjs';
|
|
29
47
|
import {
|
|
30
48
|
extractPastedFiles,
|
|
31
49
|
extractDroppedFiles,
|
|
@@ -1752,6 +1770,371 @@ function updateRepoToolbarUi() {
|
|
|
1752
1770
|
heavyBtn.textContent = workspaceRoot
|
|
1753
1771
|
? `Heavy: ${repoBrowserState.workspaceIncludeHeavy ? 'On' : 'Off'}`
|
|
1754
1772
|
: 'Heavy: n/a';
|
|
1773
|
+
const newFolderBtn = document.getElementById('repo-new-folder-btn');
|
|
1774
|
+
if (newFolderBtn) newFolderBtn.disabled = !canCreateRepoFolderIn(String(repoBrowserState.currentPath || ''));
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// ---------------------------------------------------------------------------
|
|
1778
|
+
// Explorer edits: new folder, rename, delete.
|
|
1779
|
+
//
|
|
1780
|
+
// The two inline editors live in module state, not in repoBrowserState: they
|
|
1781
|
+
// are transient, belong to one open explorer, and must not survive a root
|
|
1782
|
+
// switch or a close. Both are rendered by the normal render functions, which
|
|
1783
|
+
// capture the typed text and the focus before they replace the markup, so a
|
|
1784
|
+
// background re-render (a child load landing) does not eat a half-typed name.
|
|
1785
|
+
// ---------------------------------------------------------------------------
|
|
1786
|
+
|
|
1787
|
+
/** `{ parentPath, value, error, busy }` while the New-folder row is open. */
|
|
1788
|
+
let repoNewFolderDraft = null;
|
|
1789
|
+
/** `{ path, value, busy }` while a name is being edited in place. */
|
|
1790
|
+
let repoRenameDraft = null;
|
|
1791
|
+
/** `{ path, name, kind }` while the delete confirmation is open. */
|
|
1792
|
+
let repoDeletePending = null;
|
|
1793
|
+
/** Set while a render swaps markup, so the blur it causes is not a cancel. */
|
|
1794
|
+
let repoInlineRenderInProgress = false;
|
|
1795
|
+
|
|
1796
|
+
/** Rename and delete exist in the workspace and drives roots, outside the CWD picker. */
|
|
1797
|
+
function canEditRepoEntries() {
|
|
1798
|
+
return repoBrowserState.activeRoot !== 'session' && !repoBrowserCwdPickModeActive();
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** A folder can be created wherever a real directory is open; the Windows "Drives" list is not one. */
|
|
1802
|
+
function canCreateRepoFolderIn(dirPath) {
|
|
1803
|
+
if (repoBrowserState.loading || repoBrowserState.error || !repoBrowserState.tree) return false;
|
|
1804
|
+
const node = repoBrowserState.nodeMap.get(String(dirPath || ''));
|
|
1805
|
+
if (!node || node.type !== 'dir') return false;
|
|
1806
|
+
if (repoBrowserState.activeRoot === 'drives' && !String(node.path || '')) return false;
|
|
1807
|
+
return true;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function clearRepoInlineEdits() {
|
|
1811
|
+
repoNewFolderDraft = null;
|
|
1812
|
+
repoRenameDraft = null;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
export function startRepoNewFolder(parentPath = null) {
|
|
1816
|
+
const target = parentPath === null ? String(repoBrowserState.currentPath || '') : String(parentPath || '');
|
|
1817
|
+
if (!canCreateRepoFolderIn(target)) return;
|
|
1818
|
+
repoRenameDraft = null;
|
|
1819
|
+
repoNewFolderDraft = { parentPath: target, value: '', error: '', busy: false };
|
|
1820
|
+
renderRepoBrowser();
|
|
1821
|
+
focusRepoInlineInput('[data-repo-new-folder-input]');
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
export function cancelRepoNewFolder() {
|
|
1825
|
+
if (!repoNewFolderDraft || repoNewFolderDraft.busy) return;
|
|
1826
|
+
repoNewFolderDraft = null;
|
|
1827
|
+
renderRepoFolder();
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
export async function commitRepoNewFolder() {
|
|
1831
|
+
const draft = repoNewFolderDraft;
|
|
1832
|
+
if (!draft || draft.busy) return;
|
|
1833
|
+
const name = String(draft.value || '').trim();
|
|
1834
|
+
if (!name) {
|
|
1835
|
+
draft.error = 'Enter a name';
|
|
1836
|
+
renderRepoFolder();
|
|
1837
|
+
focusRepoInlineInput('[data-repo-new-folder-input]');
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
draft.busy = true;
|
|
1841
|
+
draft.error = '';
|
|
1842
|
+
renderRepoFolder();
|
|
1843
|
+
const root = repoBrowserState.activeRoot;
|
|
1844
|
+
try {
|
|
1845
|
+
const result = await createExplorerFolder({
|
|
1846
|
+
root,
|
|
1847
|
+
path: draft.parentPath,
|
|
1848
|
+
name,
|
|
1849
|
+
conversationId: currentConversationId(),
|
|
1850
|
+
});
|
|
1851
|
+
if (repoNewFolderDraft !== draft) return;
|
|
1852
|
+
repoNewFolderDraft = null;
|
|
1853
|
+
const newPath = String(result?.path || childPathOf(draft.parentPath, name));
|
|
1854
|
+
await reloadRepoDirectory(draft.parentPath);
|
|
1855
|
+
if (repoBrowserState.activeRoot === root && repoBrowserState.nodeMap.has(newPath)) {
|
|
1856
|
+
await setRepoCurrentPath(newPath);
|
|
1857
|
+
}
|
|
1858
|
+
showTransientRelayNotice(`Folder "${name}" created.`);
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
if (repoNewFolderDraft !== draft) return;
|
|
1861
|
+
draft.busy = false;
|
|
1862
|
+
draft.error = error?.message || 'Failed to create the folder';
|
|
1863
|
+
renderRepoFolder();
|
|
1864
|
+
focusRepoInlineInput('[data-repo-new-folder-input]');
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
export function startRepoRename(pathValue) {
|
|
1869
|
+
const entryPath = String(pathValue || '');
|
|
1870
|
+
const node = repoBrowserState.nodeMap.get(entryPath);
|
|
1871
|
+
if (!node || !entryPath || !canEditRepoEntries()) return;
|
|
1872
|
+
repoNewFolderDraft = null;
|
|
1873
|
+
repoRenameDraft = { path: entryPath, value: String(node.name || entryNameOf(entryPath)), busy: false };
|
|
1874
|
+
renderRepoBrowser();
|
|
1875
|
+
focusRepoInlineInput('[data-repo-rename-input]', { select: true });
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
export function cancelRepoRename() {
|
|
1879
|
+
if (!repoRenameDraft || repoRenameDraft.busy) return;
|
|
1880
|
+
repoRenameDraft = null;
|
|
1881
|
+
renderRepoBrowser();
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
export async function commitRepoRename() {
|
|
1885
|
+
const draft = repoRenameDraft;
|
|
1886
|
+
if (!draft || draft.busy) return;
|
|
1887
|
+
const node = repoBrowserState.nodeMap.get(draft.path);
|
|
1888
|
+
const oldName = String(node?.name || entryNameOf(draft.path));
|
|
1889
|
+
const newName = String(draft.value || '').trim();
|
|
1890
|
+
if (!node || !newName || newName === oldName) {
|
|
1891
|
+
cancelRepoRename();
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
draft.busy = true;
|
|
1895
|
+
renderRepoBrowser();
|
|
1896
|
+
const root = repoBrowserState.activeRoot;
|
|
1897
|
+
try {
|
|
1898
|
+
const result = await renameExplorerEntry({
|
|
1899
|
+
root,
|
|
1900
|
+
path: draft.path,
|
|
1901
|
+
newName,
|
|
1902
|
+
conversationId: currentConversationId(),
|
|
1903
|
+
});
|
|
1904
|
+
if (repoRenameDraft !== draft) return;
|
|
1905
|
+
repoRenameDraft = null;
|
|
1906
|
+
const parent = parentPathOf(draft.path);
|
|
1907
|
+
const newPath = String(result?.path || childPathOf(parent, newName));
|
|
1908
|
+
repoBrowserState.expandedPaths = rewritePathSet(repoBrowserState.expandedPaths, draft.path, newPath);
|
|
1909
|
+
repoBrowserState.collapsedPaths = rewritePathSet(repoBrowserState.collapsedPaths, draft.path, newPath);
|
|
1910
|
+
repoBrowserState.currentPath = rewritePathPrefix(repoBrowserState.currentPath, draft.path, newPath);
|
|
1911
|
+
await reloadRepoDirectory(parent);
|
|
1912
|
+
showTransientRelayNotice(`Renamed to "${newName}".`);
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
if (repoRenameDraft !== draft) return;
|
|
1915
|
+
draft.busy = false;
|
|
1916
|
+
renderRepoBrowser();
|
|
1917
|
+
focusRepoInlineInput('[data-repo-rename-input]', { select: true });
|
|
1918
|
+
showTransientRelayNotice(error?.message || 'Failed to rename', 6000);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function deleteConfirmationBody(pending, counts) {
|
|
1923
|
+
const name = escHtml(pending.name);
|
|
1924
|
+
const needsTypedName = counts.kind === 'dir' && (counts.files + counts.dirs) > 0;
|
|
1925
|
+
let lead;
|
|
1926
|
+
if (counts.kind !== 'dir') {
|
|
1927
|
+
lead = `<p>Delete the file <strong>${name}</strong>?</p>`;
|
|
1928
|
+
} else if (!needsTypedName) {
|
|
1929
|
+
lead = `<p>Delete the empty folder <strong>${name}</strong>?</p>`;
|
|
1930
|
+
} else {
|
|
1931
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
1932
|
+
const amount = counts.capped
|
|
1933
|
+
? 'more than 10,000 entries'
|
|
1934
|
+
: `${plural(counts.files, 'file')} and ${plural(counts.dirs, 'folder')}`;
|
|
1935
|
+
lead = `<p>Delete the folder <strong>${name}</strong> and everything in it?</p>
|
|
1936
|
+
<p>It contains ${amount}.</p>
|
|
1937
|
+
<label class="repo-delete-confirm-label">Type the folder name to confirm
|
|
1938
|
+
<input type="text" class="repo-delete-confirm-input" data-repo-delete-confirm-input autocomplete="off" spellcheck="false" autocapitalize="off" placeholder="${name}">
|
|
1939
|
+
</label>`;
|
|
1940
|
+
}
|
|
1941
|
+
return `${lead}
|
|
1942
|
+
<div class="summary-modal-actions">
|
|
1943
|
+
<button class="chat-title-action-btn danger-btn" type="button" data-repo-delete-confirm${needsTypedName ? ' disabled data-keep-disabled="1"' : ''}>🗑 Delete</button>
|
|
1944
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
|
|
1945
|
+
</div>`;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
export async function openRepoDeleteConfirmation(pathValue) {
|
|
1949
|
+
const entryPath = String(pathValue || '');
|
|
1950
|
+
const node = repoBrowserState.nodeMap.get(entryPath);
|
|
1951
|
+
if (!node || !entryPath || !canEditRepoEntries()) return;
|
|
1952
|
+
const pending = { path: entryPath, name: String(node.name || entryNameOf(entryPath)), kind: node.type === 'dir' ? 'dir' : 'file', root: repoBrowserState.activeRoot };
|
|
1953
|
+
repoDeletePending = pending;
|
|
1954
|
+
openSummaryModal({
|
|
1955
|
+
title: 'Delete',
|
|
1956
|
+
subtitle: entryPath,
|
|
1957
|
+
kind: 'explorer-delete',
|
|
1958
|
+
bodyHtml: '<p class="repo-empty">Counting entries…</p>',
|
|
1959
|
+
});
|
|
1960
|
+
let counts;
|
|
1961
|
+
try {
|
|
1962
|
+
counts = await deleteExplorerEntry({ root: pending.root, path: entryPath, conversationId: currentConversationId(), dryRun: true });
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
if (repoDeletePending !== pending) return;
|
|
1965
|
+
repoDeletePending = null;
|
|
1966
|
+
closeSummaryModal();
|
|
1967
|
+
showTransientRelayNotice(error?.message || 'Failed to read the entry', 6000);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (repoDeletePending !== pending) return;
|
|
1971
|
+
renderSummaryModalContent({
|
|
1972
|
+
title: 'Delete',
|
|
1973
|
+
subtitle: entryPath,
|
|
1974
|
+
kind: 'explorer-delete',
|
|
1975
|
+
bodyHtml: deleteConfirmationBody(pending, {
|
|
1976
|
+
kind: String(counts?.kind || pending.kind),
|
|
1977
|
+
files: Number(counts?.files || 0),
|
|
1978
|
+
dirs: Number(counts?.dirs || 0),
|
|
1979
|
+
capped: counts?.capped === true,
|
|
1980
|
+
}),
|
|
1981
|
+
});
|
|
1982
|
+
const input = document.querySelector('#summary-modal [data-repo-delete-confirm-input]');
|
|
1983
|
+
if (input) input.focus();
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
async function confirmRepoDelete() {
|
|
1987
|
+
const pending = repoDeletePending;
|
|
1988
|
+
if (!pending) return;
|
|
1989
|
+
const button = document.querySelector('#summary-modal [data-repo-delete-confirm]');
|
|
1990
|
+
if (button?.disabled) return;
|
|
1991
|
+
setSummaryModalLoading(true);
|
|
1992
|
+
try {
|
|
1993
|
+
await deleteExplorerEntry({ root: pending.root, path: pending.path, conversationId: currentConversationId() });
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
if (repoDeletePending !== pending) return;
|
|
1996
|
+
setSummaryModalLoading(false);
|
|
1997
|
+
showTransientRelayNotice(error?.message || 'Failed to delete', 6000);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (repoDeletePending !== pending) return;
|
|
2001
|
+
repoDeletePending = null;
|
|
2002
|
+
closeSummaryModal();
|
|
2003
|
+
if (repoBrowserState.activeRoot !== pending.root) return;
|
|
2004
|
+
const parent = parentPathOf(pending.path);
|
|
2005
|
+
repoBrowserState.expandedPaths = dropPathsBelow(repoBrowserState.expandedPaths, pending.path);
|
|
2006
|
+
repoBrowserState.collapsedPaths = dropPathsBelow(repoBrowserState.collapsedPaths, pending.path);
|
|
2007
|
+
if (isSameOrBelow(repoBrowserState.currentPath, pending.path)) repoBrowserState.currentPath = parent;
|
|
2008
|
+
await reloadRepoDirectory(parent);
|
|
2009
|
+
showTransientRelayNotice(`Deleted "${pending.name}".`);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Refetch one folder's children after a change in it, then re-expand what was
|
|
2014
|
+
* open below it. The session root itself is reloaded whole: its listing is
|
|
2015
|
+
* special (a missing folder lists as empty, the transcript is a sibling) and
|
|
2016
|
+
* the plain children route would not do.
|
|
2017
|
+
*/
|
|
2018
|
+
async function reloadRepoDirectory(dirPath) {
|
|
2019
|
+
const target = String(dirPath || '');
|
|
2020
|
+
const node = repoBrowserState.nodeMap.get(target);
|
|
2021
|
+
const sessionRootItself = repoBrowserState.activeRoot === 'session' && target === String(repoBrowserState.tree?.path || '');
|
|
2022
|
+
if (!node || node.type !== 'dir' || sessionRootItself) {
|
|
2023
|
+
refreshRepoBrowser();
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
const reopen = descendantsOf(repoBrowserState.expandedPaths, target);
|
|
2027
|
+
node.childrenLoaded = false;
|
|
2028
|
+
node.lazy = true;
|
|
2029
|
+
await ensureRepoChildrenLoaded(target);
|
|
2030
|
+
for (const descendant of reopen) {
|
|
2031
|
+
if (repoBrowserState.nodeMap.has(descendant)) await ensureRepoChildrenLoaded(descendant);
|
|
2032
|
+
}
|
|
2033
|
+
if (!repoBrowserState.nodeMap.has(String(repoBrowserState.currentPath || ''))) {
|
|
2034
|
+
repoBrowserState.currentPath = target;
|
|
2035
|
+
}
|
|
2036
|
+
renderRepoBrowser();
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
function focusRepoInlineInput(selector, { select = false } = {}) {
|
|
2040
|
+
requestAnimationFrame(() => {
|
|
2041
|
+
const input = document.querySelector(`#repo-browser-modal ${selector}`);
|
|
2042
|
+
if (!input) return;
|
|
2043
|
+
input.focus();
|
|
2044
|
+
if (select) {
|
|
2045
|
+
// Select the stem only: the common rename keeps the extension.
|
|
2046
|
+
const value = String(input.value || '');
|
|
2047
|
+
const dot = value.lastIndexOf('.');
|
|
2048
|
+
input.setSelectionRange(0, dot > 0 ? dot : value.length);
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
/** Before a render replaces the markup: keep the typed text and whether it had focus. */
|
|
2054
|
+
function captureRepoInlineEdits() {
|
|
2055
|
+
const newFolderInput = document.querySelector('#repo-folder [data-repo-new-folder-input]');
|
|
2056
|
+
if (newFolderInput && repoNewFolderDraft) {
|
|
2057
|
+
repoNewFolderDraft.value = newFolderInput.value;
|
|
2058
|
+
repoNewFolderDraft.focused = document.activeElement === newFolderInput;
|
|
2059
|
+
}
|
|
2060
|
+
const renameInput = document.querySelector('#repo-browser-modal [data-repo-rename-input]');
|
|
2061
|
+
if (renameInput && repoRenameDraft) {
|
|
2062
|
+
repoRenameDraft.value = renameInput.value;
|
|
2063
|
+
repoRenameDraft.focused = document.activeElement === renameInput;
|
|
2064
|
+
repoRenameDraft.selection = [renameInput.selectionStart, renameInput.selectionEnd];
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** After the render: put the focus back where it was. */
|
|
2069
|
+
function restoreRepoInlineEditFocus() {
|
|
2070
|
+
if (repoNewFolderDraft?.focused) {
|
|
2071
|
+
const input = document.querySelector('#repo-folder [data-repo-new-folder-input]');
|
|
2072
|
+
if (input && document.activeElement !== input) input.focus();
|
|
2073
|
+
}
|
|
2074
|
+
if (repoRenameDraft?.focused) {
|
|
2075
|
+
const input = document.querySelector('#repo-browser-modal [data-repo-rename-input]');
|
|
2076
|
+
if (input && document.activeElement !== input) {
|
|
2077
|
+
input.focus();
|
|
2078
|
+
const [start, end] = repoRenameDraft.selection || [];
|
|
2079
|
+
if (Number.isFinite(start) && Number.isFinite(end)) input.setSelectionRange(start, end);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/** Run `swap` (which replaces markup) with the inline edits preserved across it. */
|
|
2085
|
+
function withRepoInlineEditsPreserved(swap) {
|
|
2086
|
+
captureRepoInlineEdits();
|
|
2087
|
+
repoInlineRenderInProgress = true;
|
|
2088
|
+
try {
|
|
2089
|
+
swap();
|
|
2090
|
+
} finally {
|
|
2091
|
+
repoInlineRenderInProgress = false;
|
|
2092
|
+
}
|
|
2093
|
+
restoreRepoInlineEditFocus();
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function renameInputHtml(draft) {
|
|
2097
|
+
return `<input type="text" class="repo-rename-input" data-repo-rename-input value="${escHtml(draft.value)}" autocomplete="off" spellcheck="false" autocapitalize="off" aria-label="New name"${draft.busy ? ' disabled' : ''}>`;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
function newFolderRowHtml(draft) {
|
|
2101
|
+
return `<form class="repo-inline-edit" data-repo-new-folder-form>
|
|
2102
|
+
<span class="repo-inline-edit-icon">📁</span>
|
|
2103
|
+
<input type="text" data-repo-new-folder-input value="${escHtml(draft.value)}" placeholder="New folder name" autocomplete="off" spellcheck="false" autocapitalize="off" aria-label="New folder name"${draft.busy ? ' disabled' : ''}>
|
|
2104
|
+
<button type="submit" class="repo-browser-btn"${draft.busy ? ' disabled' : ''}>${draft.busy ? 'Creating…' : 'Create'}</button>
|
|
2105
|
+
<button type="button" class="repo-browser-btn" data-repo-new-folder-cancel${draft.busy ? ' disabled' : ''}>Cancel</button>
|
|
2106
|
+
<div class="repo-inline-edit-error" aria-live="polite">${escHtml(draft.error || '')}</div>
|
|
2107
|
+
</form>`;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
function repoEntryMenuItems(entryPath, isDir) {
|
|
2111
|
+
const items = [];
|
|
2112
|
+
if (isDir && canCreateRepoFolderIn(entryPath)) {
|
|
2113
|
+
items.push({ id: 'new-folder', label: '📁 New folder here', onSelect: () => { void setRepoCurrentPath(entryPath).then(() => startRepoNewFolder(entryPath)); } });
|
|
2114
|
+
}
|
|
2115
|
+
// Roots are never renamed or deleted: the workspace root, `/`, the session
|
|
2116
|
+
// root, and a drive (`C:`), which on Windows is a top-level child of the list.
|
|
2117
|
+
const isRoot = !entryPath
|
|
2118
|
+
|| entryPath === '/'
|
|
2119
|
+
|| entryPath === String(repoBrowserState.tree?.path || '')
|
|
2120
|
+
|| (repoBrowserState.activeRoot !== 'workspace' && !parentPathOf(entryPath));
|
|
2121
|
+
if (canEditRepoEntries() && !isRoot) {
|
|
2122
|
+
if (items.length) items.push(MENU_SEPARATOR);
|
|
2123
|
+
items.push({ id: 'rename', label: 'Rename', onSelect: () => startRepoRename(entryPath) });
|
|
2124
|
+
items.push({ id: 'delete', label: 'Delete', danger: true, onSelect: () => { void openRepoDeleteConfirmation(entryPath); } });
|
|
2125
|
+
}
|
|
2126
|
+
return items;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function openRepoEntryMenu(element, x, y) {
|
|
2130
|
+
const dirPath = element.getAttribute('data-repo-open-dir');
|
|
2131
|
+
const filePath = element.getAttribute('data-repo-open-file');
|
|
2132
|
+
const entryPath = dirPath !== null ? dirPath : filePath;
|
|
2133
|
+
if (entryPath === null) return false;
|
|
2134
|
+
const items = repoEntryMenuItems(String(entryPath), dirPath !== null);
|
|
2135
|
+
if (!items.length) return false;
|
|
2136
|
+
openContextMenu(x, y, items);
|
|
2137
|
+
return true;
|
|
1755
2138
|
}
|
|
1756
2139
|
|
|
1757
2140
|
export function renderRepoTreeNode(node) {
|
|
@@ -1780,8 +2163,11 @@ export function renderRepoTreeNode(node) {
|
|
|
1780
2163
|
: (lazyUnloaded
|
|
1781
2164
|
? '<div class="repo-empty">Expand to load entries…</div>'
|
|
1782
2165
|
: children.map(renderRepoTreeNode).join(''));
|
|
2166
|
+
const label = repoRenameDraft && repoRenameDraft.path === nodePath && nodePath
|
|
2167
|
+
? renameInputHtml(repoRenameDraft)
|
|
2168
|
+
: escHtml(node.name || '/');
|
|
1783
2169
|
return `<details class="repo-tree-node" data-repo-dir-path="${escHtml(nodePath)}"${openAttr}>
|
|
1784
|
-
<summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${
|
|
2170
|
+
<summary class="repo-tree-summary" data-repo-open-dir="${escHtml(nodePath)}">${icon} ${label}</summary>
|
|
1785
2171
|
<div class="repo-tree-children">${childrenHtml}</div>
|
|
1786
2172
|
</details>`;
|
|
1787
2173
|
}
|
|
@@ -1895,8 +2281,13 @@ export function renderRepoFolder() {
|
|
|
1895
2281
|
const children = Array.isArray(node.children)
|
|
1896
2282
|
? node.children.filter((child) => child?.type !== 'dir')
|
|
1897
2283
|
: [];
|
|
2284
|
+
const draftHtml = repoNewFolderDraft && repoNewFolderDraft.parentPath === String(node.path || '')
|
|
2285
|
+
? newFolderRowHtml(repoNewFolderDraft)
|
|
2286
|
+
: '';
|
|
1898
2287
|
if (!children.length) {
|
|
1899
|
-
|
|
2288
|
+
withRepoInlineEditsPreserved(() => {
|
|
2289
|
+
folderHost.innerHTML = `${draftHtml}<div class="repo-empty">This folder has no files.</div>`;
|
|
2290
|
+
});
|
|
1900
2291
|
return;
|
|
1901
2292
|
}
|
|
1902
2293
|
|
|
@@ -1906,6 +2297,8 @@ export function renderRepoFolder() {
|
|
|
1906
2297
|
const childPath = String(child.path || '');
|
|
1907
2298
|
const rawHref = repoRawHref(childPath);
|
|
1908
2299
|
const icon = repoIcon(child);
|
|
2300
|
+
const renaming = !!repoRenameDraft && repoRenameDraft.path === childPath;
|
|
2301
|
+
const nameHtml = renaming ? renameInputHtml(repoRenameDraft) : escHtml(child.name || childPath);
|
|
1909
2302
|
if (isGrid) {
|
|
1910
2303
|
const ext = String(child.ext || '').replace('.', '');
|
|
1911
2304
|
const isImage = String(child.previewKind || '').toLowerCase() === 'image' || REPO_IMAGE_EXTENSIONS.has(ext.toLowerCase());
|
|
@@ -1915,7 +2308,7 @@ export function renderRepoFolder() {
|
|
|
1915
2308
|
const fileMeta = `${String(child.previewKind || 'file')} · ${formatBytes(child.size || 0)}`;
|
|
1916
2309
|
return `<div class="repo-card repo-card-clickable" data-repo-open-file="${escHtml(childPath)}">
|
|
1917
2310
|
<div class="repo-card-thumb">${thumb}</div>
|
|
1918
|
-
<div class="repo-card-name" title="${escHtml(child.name || childPath)}">${
|
|
2311
|
+
<div class="repo-card-name" title="${escHtml(child.name || childPath)}">${nameHtml}</div>
|
|
1919
2312
|
<div class="repo-entry-meta">${escHtml(fileMeta)}</div>
|
|
1920
2313
|
</div>`;
|
|
1921
2314
|
}
|
|
@@ -1925,7 +2318,7 @@ export function renderRepoFolder() {
|
|
|
1925
2318
|
<div class="repo-entry-main">
|
|
1926
2319
|
<span>${icon}</span>
|
|
1927
2320
|
<div style="min-width:0">
|
|
1928
|
-
<div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${
|
|
2321
|
+
<div class="repo-entry-name" title="${escHtml(child.name || childPath)}">${nameHtml}</div>
|
|
1929
2322
|
<div class="repo-entry-meta">${escHtml(mainMeta)}</div>
|
|
1930
2323
|
</div>
|
|
1931
2324
|
</div>
|
|
@@ -1933,7 +2326,9 @@ export function renderRepoFolder() {
|
|
|
1933
2326
|
}).join('');
|
|
1934
2327
|
|
|
1935
2328
|
const savedScrollTop = folderHost.scrollTop;
|
|
1936
|
-
|
|
2329
|
+
withRepoInlineEditsPreserved(() => {
|
|
2330
|
+
folderHost.innerHTML = `${draftHtml}<div class="${wrapperClass}">${rows}</div>`;
|
|
2331
|
+
});
|
|
1937
2332
|
folderHost.scrollTop = savedScrollTop;
|
|
1938
2333
|
}
|
|
1939
2334
|
|
|
@@ -1956,7 +2351,9 @@ export function renderRepoTree() {
|
|
|
1956
2351
|
// The rebuild resets the pane's scroll to 0; put it back so re-renders
|
|
1957
2352
|
// (child loads, refreshes) don't rubber-band a scroll in progress.
|
|
1958
2353
|
const savedScrollTop = treeHost.scrollTop;
|
|
1959
|
-
|
|
2354
|
+
withRepoInlineEditsPreserved(() => {
|
|
2355
|
+
treeHost.innerHTML = renderRepoTreeNode(root);
|
|
2356
|
+
});
|
|
1960
2357
|
updateRepoTreeSelection();
|
|
1961
2358
|
treeHost.scrollTop = savedScrollTop;
|
|
1962
2359
|
syncRepoTreeToCurrentPath();
|
|
@@ -2181,6 +2578,7 @@ export function setRepoBrowserRoot(root) {
|
|
|
2181
2578
|
// A root switch invalidates any parked restore: the saved path belongs to the
|
|
2182
2579
|
// tree we are leaving. The Set wipes below are correct here for the same reason.
|
|
2183
2580
|
pendingRepoBrowserRestore = null;
|
|
2581
|
+
clearRepoInlineEdits();
|
|
2184
2582
|
setRepoBrowserState({
|
|
2185
2583
|
tree: null,
|
|
2186
2584
|
nodeMap: new Map(),
|
|
@@ -2330,6 +2728,7 @@ export function closeRepoBrowser() {
|
|
|
2330
2728
|
modal.setAttribute('aria-hidden', 'true');
|
|
2331
2729
|
repoBrowserState.open = false;
|
|
2332
2730
|
repoBrowserCwdPickHandler = null;
|
|
2731
|
+
clearRepoInlineEdits();
|
|
2333
2732
|
}
|
|
2334
2733
|
|
|
2335
2734
|
export function refreshRepoBrowser() {
|
|
@@ -2427,6 +2826,8 @@ export async function setRepoCurrentPath(pathValue) {
|
|
|
2427
2826
|
updateRepoTreeSelection();
|
|
2428
2827
|
syncRepoTreeToCurrentPath();
|
|
2429
2828
|
updateRepoCwdPickBar();
|
|
2829
|
+
// The New-folder button follows the open folder (a drive list has none).
|
|
2830
|
+
updateRepoToolbarUi();
|
|
2430
2831
|
}
|
|
2431
2832
|
|
|
2432
2833
|
document.addEventListener('click', (event) => {
|
|
@@ -2596,7 +2997,105 @@ document.getElementById('repo-browser-modal').addEventListener('click', (event)
|
|
|
2596
2997
|
if (event.target.id === 'repo-browser-modal') closeRepoBrowser();
|
|
2597
2998
|
});
|
|
2598
2999
|
|
|
3000
|
+
// The inline editors. Keys inside an input never reach the <summary> (Enter
|
|
3001
|
+
// would toggle the folder) or the document (Escape would close the explorer).
|
|
3002
|
+
function repoInlineInputOf(event) {
|
|
3003
|
+
return eventClosest(event, '[data-repo-rename-input], [data-repo-new-folder-input]');
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
document.getElementById('repo-browser-modal').addEventListener('keydown', (event) => {
|
|
3007
|
+
const input = repoInlineInputOf(event);
|
|
3008
|
+
if (!input) return;
|
|
3009
|
+
const isRename = input.hasAttribute('data-repo-rename-input');
|
|
3010
|
+
if (event.key === 'Enter') {
|
|
3011
|
+
event.preventDefault();
|
|
3012
|
+
event.stopPropagation();
|
|
3013
|
+
if (isRename) void commitRepoRename();
|
|
3014
|
+
else void commitRepoNewFolder();
|
|
3015
|
+
} else if (event.key === 'Escape') {
|
|
3016
|
+
event.preventDefault();
|
|
3017
|
+
event.stopPropagation();
|
|
3018
|
+
if (isRename) cancelRepoRename();
|
|
3019
|
+
else cancelRepoNewFolder();
|
|
3020
|
+
} else if (event.key === ' ' && isRename) {
|
|
3021
|
+
// A space in a <summary> toggles it; in a name it is a space.
|
|
3022
|
+
event.stopPropagation();
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
|
|
3026
|
+
document.getElementById('repo-browser-modal').addEventListener('input', (event) => {
|
|
3027
|
+
const input = repoInlineInputOf(event);
|
|
3028
|
+
if (!input) return;
|
|
3029
|
+
if (input.hasAttribute('data-repo-rename-input') && repoRenameDraft) repoRenameDraft.value = input.value;
|
|
3030
|
+
if (input.hasAttribute('data-repo-new-folder-input') && repoNewFolderDraft) repoNewFolderDraft.value = input.value;
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
// Leaving an untouched rename input ends the edit; a changed name stays open
|
|
3034
|
+
// until Enter, so a stray tap cannot lose or commit it. Renders swap the
|
|
3035
|
+
// input out and back, and that blur is not a cancel.
|
|
3036
|
+
document.getElementById('repo-browser-modal').addEventListener('focusout', (event) => {
|
|
3037
|
+
if (repoInlineRenderInProgress) return;
|
|
3038
|
+
const input = eventClosest(event, '[data-repo-rename-input]');
|
|
3039
|
+
if (!input || !repoRenameDraft || repoRenameDraft.busy) return;
|
|
3040
|
+
const node = repoBrowserState.nodeMap.get(repoRenameDraft.path);
|
|
3041
|
+
const original = String(node?.name || entryNameOf(repoRenameDraft.path));
|
|
3042
|
+
if (String(input.value || '').trim() === original) cancelRepoRename();
|
|
3043
|
+
});
|
|
3044
|
+
|
|
3045
|
+
document.getElementById('repo-folder').addEventListener('submit', (event) => {
|
|
3046
|
+
if (!eventClosest(event, '[data-repo-new-folder-form]')) return;
|
|
3047
|
+
event.preventDefault();
|
|
3048
|
+
void commitRepoNewFolder();
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
// Right-click on a desktop, long press on a phone: the entry's menu.
|
|
3052
|
+
for (const hostId of ['repo-tree', 'repo-folder']) {
|
|
3053
|
+
const host = document.getElementById(hostId);
|
|
3054
|
+
const selector = hostId === 'repo-tree' ? '[data-repo-open-dir]' : '[data-repo-open-file]';
|
|
3055
|
+
host.addEventListener('contextmenu', (event) => {
|
|
3056
|
+
if (repoInlineInputOf(event)) return;
|
|
3057
|
+
const element = eventClosest(event, selector);
|
|
3058
|
+
if (!element) return;
|
|
3059
|
+
if (openRepoEntryMenu(element, event.clientX, event.clientY)) event.preventDefault();
|
|
3060
|
+
});
|
|
3061
|
+
bindLongPress(host, selector, (element, x, y) => {
|
|
3062
|
+
if (element.querySelector('[data-repo-rename-input]')) return;
|
|
3063
|
+
openRepoEntryMenu(element, x, y);
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// The delete confirmation lives in the shared summary modal.
|
|
3068
|
+
document.getElementById('summary-modal').addEventListener('click', (event) => {
|
|
3069
|
+
if (!eventClosest(event, '[data-repo-delete-confirm]')) return;
|
|
3070
|
+
event.preventDefault();
|
|
3071
|
+
void confirmRepoDelete();
|
|
3072
|
+
});
|
|
3073
|
+
|
|
3074
|
+
document.getElementById('summary-modal').addEventListener('input', (event) => {
|
|
3075
|
+
const input = eventClosest(event, '[data-repo-delete-confirm-input]');
|
|
3076
|
+
if (!input || !repoDeletePending) return;
|
|
3077
|
+
const button = document.querySelector('#summary-modal [data-repo-delete-confirm]');
|
|
3078
|
+
if (!button) return;
|
|
3079
|
+
// The modal's loading toggle re-enables every button it does not see as
|
|
3080
|
+
// kept disabled; the name gate has to be expressed through that mark.
|
|
3081
|
+
const mismatch = String(input.value || '').trim() !== repoDeletePending.name;
|
|
3082
|
+
button.disabled = mismatch;
|
|
3083
|
+
if (mismatch) button.dataset.keepDisabled = '1';
|
|
3084
|
+
else delete button.dataset.keepDisabled;
|
|
3085
|
+
});
|
|
3086
|
+
|
|
3087
|
+
document.getElementById('summary-modal').addEventListener('keydown', (event) => {
|
|
3088
|
+
if (event.key !== 'Enter' || !eventClosest(event, '[data-repo-delete-confirm-input]')) return;
|
|
3089
|
+
event.preventDefault();
|
|
3090
|
+
void confirmRepoDelete();
|
|
3091
|
+
});
|
|
3092
|
+
|
|
2599
3093
|
document.getElementById('repo-tree').addEventListener('click', (event) => {
|
|
3094
|
+
if (repoInlineInputOf(event)) {
|
|
3095
|
+
// A click in the rename input must not toggle the folder it sits in.
|
|
3096
|
+
event.preventDefault();
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
2600
3099
|
const fileButton = eventClosest(event, '[data-repo-open-file]');
|
|
2601
3100
|
if (fileButton) {
|
|
2602
3101
|
event.preventDefault();
|
|
@@ -2641,6 +3140,12 @@ document.getElementById('repo-tree').addEventListener('click', (event) => {
|
|
|
2641
3140
|
});
|
|
2642
3141
|
|
|
2643
3142
|
document.getElementById('repo-folder').addEventListener('click', (event) => {
|
|
3143
|
+
if (eventClosest(event, '[data-repo-new-folder-cancel]')) {
|
|
3144
|
+
event.preventDefault();
|
|
3145
|
+
cancelRepoNewFolder();
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
if (eventClosest(event, '[data-repo-new-folder-form]') || repoInlineInputOf(event)) return;
|
|
2644
3149
|
const navDir = eventClosest(event, '[data-repo-nav-dir]');
|
|
2645
3150
|
if (navDir) {
|
|
2646
3151
|
event.preventDefault();
|
|
@@ -2677,7 +3182,12 @@ document.addEventListener('keydown', (event) => {
|
|
|
2677
3182
|
const modal = document.getElementById('file-preview-modal');
|
|
2678
3183
|
const repoModal = document.getElementById('repo-browser-modal');
|
|
2679
3184
|
const summaryModal = document.getElementById('summary-modal');
|
|
3185
|
+
// Topmost first. The summary modal sits above the explorer (a delete
|
|
3186
|
+
// confirmation opened from it) except in CWD-pick mode, where the explorer
|
|
3187
|
+
// is raised above the new-chat/summary modal that opened it.
|
|
3188
|
+
const summaryOnTop = summaryModal.classList.contains('visible') && !repoBrowserCwdPickModeActive();
|
|
2680
3189
|
if (modal.classList.contains('visible')) closeFilePreview();
|
|
3190
|
+
else if (summaryOnTop) window.closeSummaryModal?.();
|
|
2681
3191
|
else if (repoModal.classList.contains('visible')) closeRepoBrowser();
|
|
2682
3192
|
else if (summaryModal.classList.contains('visible')) window.closeSummaryModal?.();
|
|
2683
3193
|
});
|