tabminal 3.0.10 → 3.0.12

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/public/app.js CHANGED
@@ -90,6 +90,12 @@ const agentSetupCopilotToken = document.getElementById(
90
90
  const agentSetupCopilotNote = document.getElementById(
91
91
  'agent-setup-copilot-note'
92
92
  );
93
+ const confirmModal = document.getElementById('confirm-modal');
94
+ const confirmModalTitle = document.getElementById('confirm-modal-title');
95
+ const confirmModalMessage = document.getElementById('confirm-modal-message');
96
+ const confirmModalNote = document.getElementById('confirm-modal-note');
97
+ const confirmModalCancel = document.getElementById('confirm-modal-cancel');
98
+ const confirmModalConfirm = document.getElementById('confirm-modal-confirm');
93
99
  const terminalWrapper = document.getElementById('terminal-wrapper');
94
100
  const editorPane = document.getElementById('editor-pane');
95
101
  // #endregion
@@ -97,6 +103,7 @@ const editorPane = document.getElementById('editor-pane');
97
103
  // #region Configuration
98
104
  const HEARTBEAT_INTERVAL_MS = 1000;
99
105
  const RECONNECT_RETRY_MS = 5000;
106
+ const FILE_TREE_REFRESH_INTERVAL_MS = 3000;
100
107
  const MAIN_SERVER_ID = 'main';
101
108
  const RUNTIME_BOOT_ID_STORAGE_KEY = 'tabminal_runtime_boot_id';
102
109
  const WORKSPACE_DEVICE_ID_STORAGE_KEY = 'tabminal_workspace_device_id';
@@ -104,6 +111,14 @@ const RECENT_AGENT_USAGE_STORAGE_KEY = 'tabminal_recent_agent_usage';
104
111
  const FILE_WORKSPACE_TAB_PREFIX = 'file:';
105
112
  const AGENT_WORKSPACE_TAB_PREFIX = 'agent:';
106
113
  const TERMINAL_WORKSPACE_TAB_KEY = 'terminal:main';
114
+ const SUPPORTED_IMAGE_EXTENSIONS = new Set([
115
+ 'png',
116
+ 'jpg',
117
+ 'jpeg',
118
+ 'gif',
119
+ 'svg',
120
+ 'webp'
121
+ ]);
107
122
  const CLOSE_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
108
123
  const AGENT_ICON_SVG = '<svg viewBox="0 0 24 24" width="17" height="17" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="2"></rect><path d="M9 7V5"></path><path d="M15 7V5"></path><path d="M12 17v2"></path><path d="M5 12H3"></path><path d="M21 12h-2"></path><path d="M9 11h.01"></path><path d="M15 11h.01"></path><path d="M9.5 14c.7.67 1.53 1 2.5 1s1.8-.33 2.5-1"></path></svg>';
109
124
  const TERMINAL_TAB_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m8 10 3 2-3 2"></path><path d="M13 15h4"></path></svg>';
@@ -118,6 +133,10 @@ const THOUGHT_SELECT_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15"
118
133
  const TERMINAL_TAB_MODE_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="M4 9h16"></path><path d="m9 15 3-3 3 3"></path></svg>';
119
134
  const TERMINAL_AUTO_MODE_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="5" rx="1.5"></rect><rect x="4" y="14" width="16" height="5" rx="1.5"></rect></svg>';
120
135
  const PLUS_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"></path><path d="M5 12h14"></path></svg>';
136
+ const RENAME_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="m12 20 7-7"></path><path d="M16 6.5a1.8 1.8 0 1 1 2.5 2.5L8 19.5 4 20l.5-4L16 6.5Z"></path></svg>';
137
+ const DELETE_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M6 7l1 12h10l1-12"></path><path d="M9 7V4h6v3"></path></svg>';
138
+ const NEW_FOLDER_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7.5A2.5 2.5 0 0 1 6 5h4l2 2h6a2.5 2.5 0 0 1 2.5 2.5V17A2.5 2.5 0 0 1 18 19.5H6A2.5 2.5 0 0 1 3.5 17Z"></path><path d="M12 10.5v5"></path><path d="M9.5 13h5"></path></svg>';
139
+ const NEW_FILE_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5h7l4 4V20.5H7A2.5 2.5 0 0 1 4.5 18V6A2.5 2.5 0 0 1 7 3.5Z"></path><path d="M14 3.5V8h4"></path><path d="M12 11v6"></path><path d="M9 14h6"></path></svg>';
121
140
  const TERMINAL_FONT_FAMILY = '\'Monaspace Neon\', "SF Mono Terminal", '
122
141
  + '"SFMono-Regular", "SF Mono", "JetBrains Mono", Menlo, Consolas, '
123
142
  + 'monospace';
@@ -170,6 +189,18 @@ function isCompactWorkspaceMode() {
170
189
  return !!window.__tabminalCompactWorkspaceMode;
171
190
  }
172
191
 
192
+ function isSupportedImagePath(filePath) {
193
+ if (typeof filePath !== 'string') {
194
+ return false;
195
+ }
196
+ const dotIndex = filePath.lastIndexOf('.');
197
+ if (dotIndex === -1) {
198
+ return false;
199
+ }
200
+ const ext = filePath.slice(dotIndex + 1).toLowerCase();
201
+ return SUPPORTED_IMAGE_EXTENSIONS.has(ext);
202
+ }
203
+
173
204
  function isCompactTerminalTabsMode() {
174
205
  return !!window.__tabminalCompactTerminalTabsMode;
175
206
  }
@@ -668,6 +699,7 @@ class EditorManager {
668
699
  this.currentSession = null;
669
700
  this.iconMap = null;
670
701
  this.agentTimestampTimer = null;
702
+ this.treeRefreshTimer = null;
671
703
 
672
704
  // DOM Elements
673
705
  this.pane = document.getElementById('editor-pane');
@@ -1441,90 +1473,1476 @@ class EditorManager {
1441
1473
  return session ? session.server.modelStore : null;
1442
1474
  }
1443
1475
 
1444
- getModel(filePath, session = this.currentSession) {
1445
- const store = this.getModelStore(session);
1446
- if (!store) return null;
1447
- return store.get(filePath) || null;
1448
- }
1476
+ getModel(filePath, session = this.currentSession) {
1477
+ const store = this.getModelStore(session);
1478
+ if (!store) return null;
1479
+ return store.get(filePath) || null;
1480
+ }
1481
+
1482
+ setModel(filePath, value, session = this.currentSession) {
1483
+ const store = this.getModelStore(session);
1484
+ if (!store) return;
1485
+ store.set(filePath, value);
1486
+ }
1487
+
1488
+ remapTreePath(pathValue, oldPath, newPath, isDirectory) {
1489
+ if (typeof pathValue !== 'string' || pathValue.length === 0) {
1490
+ return pathValue;
1491
+ }
1492
+ if (pathValue === oldPath) {
1493
+ return newPath;
1494
+ }
1495
+ if (
1496
+ isDirectory
1497
+ && pathValue.startsWith(`${oldPath}/`)
1498
+ ) {
1499
+ return `${newPath}${pathValue.slice(oldPath.length)}`;
1500
+ }
1501
+ return pathValue;
1502
+ }
1503
+
1504
+ remapWorkspaceTabKey(key, oldPath, newPath, isDirectory) {
1505
+ if (!isFileWorkspaceTabKey(key)) return key;
1506
+ const filePath = workspaceKeyToFilePath(key);
1507
+ const nextPath = this.remapTreePath(
1508
+ filePath,
1509
+ oldPath,
1510
+ newPath,
1511
+ isDirectory
1512
+ );
1513
+ return nextPath ? makeFileWorkspaceTabKey(nextPath) : key;
1514
+ }
1515
+
1516
+ cloneRenamedModelEntry(entry, nextPath) {
1517
+ if (!entry || typeof entry !== 'object') return entry;
1518
+ const nextEntry = {
1519
+ ...entry
1520
+ };
1521
+ if (nextEntry.model) {
1522
+ let nextContent = nextEntry.content;
1523
+ try {
1524
+ if (typeof nextEntry.model.getValue === 'function') {
1525
+ nextContent = nextEntry.model.getValue();
1526
+ }
1527
+ } catch {
1528
+ // Ignore content extraction failure and keep cached content.
1529
+ }
1530
+ nextEntry.content = nextContent;
1531
+
1532
+ if (
1533
+ this.monacoInstance
1534
+ && typeof nextEntry.model.getLanguageId === 'function'
1535
+ ) {
1536
+ const oldModel = nextEntry.model;
1537
+ const languageId = oldModel.getLanguageId();
1538
+ const uri = this.monacoInstance.Uri.file(nextPath);
1539
+ const existingModel = this.monacoInstance.editor.getModel(uri);
1540
+ if (existingModel && existingModel !== oldModel) {
1541
+ existingModel.setValue(nextContent ?? '');
1542
+ nextEntry.model = existingModel;
1543
+ } else {
1544
+ nextEntry.model = this.monacoInstance.editor.createModel(
1545
+ nextContent ?? '',
1546
+ languageId,
1547
+ uri
1548
+ );
1549
+ }
1550
+ if (nextEntry.model !== oldModel) {
1551
+ try {
1552
+ oldModel.dispose();
1553
+ } catch {
1554
+ // Ignore disposal failures for stale models.
1555
+ }
1556
+ }
1557
+ return nextEntry;
1558
+ }
1559
+ }
1560
+ return nextEntry;
1561
+ }
1562
+
1563
+ remapModelStorePaths(server, oldPath, newPath, isDirectory) {
1564
+ if (!server?.modelStore) return false;
1565
+ const nextEntries = [];
1566
+ let changed = false;
1567
+ for (const [path, entry] of server.modelStore.entries()) {
1568
+ const nextPath = this.remapTreePath(
1569
+ path,
1570
+ oldPath,
1571
+ newPath,
1572
+ isDirectory
1573
+ );
1574
+ if (nextPath !== path) {
1575
+ changed = true;
1576
+ nextEntries.push([
1577
+ nextPath,
1578
+ this.cloneRenamedModelEntry(entry, nextPath)
1579
+ ]);
1580
+ server.modelStore.delete(path);
1581
+ }
1582
+ }
1583
+ for (const [nextPath, entry] of nextEntries) {
1584
+ server.modelStore.set(nextPath, entry);
1585
+ }
1586
+ return changed;
1587
+ }
1588
+
1589
+ remapPendingFileWrites(sessionKey, oldPath, newPath, isDirectory) {
1590
+ const pending = pendingChanges.sessions.get(sessionKey);
1591
+ if (!pending?.fileWrites || pending.fileWrites.size === 0) {
1592
+ return false;
1593
+ }
1594
+ const nextEntries = [];
1595
+ let changed = false;
1596
+ for (const [path, content] of pending.fileWrites.entries()) {
1597
+ const nextPath = this.remapTreePath(
1598
+ path,
1599
+ oldPath,
1600
+ newPath,
1601
+ isDirectory
1602
+ );
1603
+ if (nextPath !== path) {
1604
+ changed = true;
1605
+ pending.fileWrites.delete(path);
1606
+ nextEntries.push([nextPath, content]);
1607
+ }
1608
+ }
1609
+ for (const [nextPath, content] of nextEntries) {
1610
+ pending.fileWrites.set(nextPath, content);
1611
+ }
1612
+ return changed;
1613
+ }
1614
+
1615
+ pathMatchesTarget(pathValue, targetPath, isDirectory) {
1616
+ if (typeof pathValue !== 'string' || pathValue.length === 0) {
1617
+ return false;
1618
+ }
1619
+ if (pathValue === targetPath) {
1620
+ return true;
1621
+ }
1622
+ return !!(
1623
+ isDirectory
1624
+ && pathValue.startsWith(`${targetPath}/`)
1625
+ );
1626
+ }
1627
+
1628
+ removeDeletedModelStorePaths(server, targetPath, isDirectory) {
1629
+ if (!server?.modelStore) return false;
1630
+ let changed = false;
1631
+ for (const [path, entry] of [...server.modelStore.entries()]) {
1632
+ if (!this.pathMatchesTarget(path, targetPath, isDirectory)) {
1633
+ continue;
1634
+ }
1635
+ changed = true;
1636
+ try {
1637
+ entry?.model?.dispose?.();
1638
+ } catch {
1639
+ // Ignore stale model disposal failures.
1640
+ }
1641
+ server.modelStore.delete(path);
1642
+ }
1643
+ return changed;
1644
+ }
1645
+
1646
+ removeDeletedPendingFileWrites(sessionKey, targetPath, isDirectory) {
1647
+ const pending = pendingChanges.sessions.get(sessionKey);
1648
+ if (!pending?.fileWrites || pending.fileWrites.size === 0) {
1649
+ return false;
1650
+ }
1651
+ let changed = false;
1652
+ for (const path of [...pending.fileWrites.keys()]) {
1653
+ if (!this.pathMatchesTarget(path, targetPath, isDirectory)) {
1654
+ continue;
1655
+ }
1656
+ changed = true;
1657
+ pending.fileWrites.delete(path);
1658
+ }
1659
+ return changed;
1660
+ }
1661
+
1662
+ applyRenamedPathToSession(session, oldPath, newPath, isDirectory) {
1663
+ let workspaceChanged = false;
1664
+ let visualChanged = false;
1665
+
1666
+ const remapList = (values) => {
1667
+ const nextValues = [];
1668
+ for (const value of values) {
1669
+ const nextValue = this.remapTreePath(
1670
+ value,
1671
+ oldPath,
1672
+ newPath,
1673
+ isDirectory
1674
+ );
1675
+ if (!nextValues.includes(nextValue)) {
1676
+ nextValues.push(nextValue);
1677
+ }
1678
+ }
1679
+ return nextValues;
1680
+ };
1681
+
1682
+ const nextOpenFiles = remapList(session.editorState.openFiles);
1683
+ if (
1684
+ JSON.stringify(nextOpenFiles)
1685
+ !== JSON.stringify(session.editorState.openFiles)
1686
+ ) {
1687
+ session.editorState.openFiles = nextOpenFiles;
1688
+ session.sharedWorkspaceState.openFiles = [...nextOpenFiles];
1689
+ workspaceChanged = true;
1690
+ visualChanged = true;
1691
+ }
1692
+
1693
+ const nextExpandedPaths = remapList(
1694
+ session.sharedWorkspaceState.expandedPaths
1695
+ );
1696
+ if (
1697
+ JSON.stringify(nextExpandedPaths)
1698
+ !== JSON.stringify(session.sharedWorkspaceState.expandedPaths)
1699
+ ) {
1700
+ session.sharedWorkspaceState.expandedPaths = nextExpandedPaths;
1701
+ workspaceChanged = true;
1702
+ }
1703
+
1704
+ const nextActiveFilePath = this.remapTreePath(
1705
+ session.editorState.activeFilePath,
1706
+ oldPath,
1707
+ newPath,
1708
+ isDirectory
1709
+ );
1710
+ if (nextActiveFilePath !== session.editorState.activeFilePath) {
1711
+ session.editorState.activeFilePath = nextActiveFilePath || null;
1712
+ visualChanged = true;
1713
+ }
1714
+
1715
+ const nextActiveTabKey = this.remapWorkspaceTabKey(
1716
+ session.workspaceState.activeTabKey,
1717
+ oldPath,
1718
+ newPath,
1719
+ isDirectory
1720
+ );
1721
+ if (nextActiveTabKey !== session.workspaceState.activeTabKey) {
1722
+ session.workspaceState.activeTabKey = nextActiveTabKey;
1723
+ visualChanged = true;
1724
+ }
1725
+
1726
+ const nextLastNonTerminalTabKey = this.remapWorkspaceTabKey(
1727
+ session.workspaceState.lastNonTerminalTabKey,
1728
+ oldPath,
1729
+ newPath,
1730
+ isDirectory
1731
+ );
1732
+ if (
1733
+ nextLastNonTerminalTabKey
1734
+ !== session.workspaceState.lastNonTerminalTabKey
1735
+ ) {
1736
+ session.workspaceState.lastNonTerminalTabKey =
1737
+ nextLastNonTerminalTabKey;
1738
+ }
1739
+
1740
+ if (session.editorState.viewStates.size > 0) {
1741
+ const nextViewStates = new Map();
1742
+ for (const [path, viewState] of session.editorState.viewStates) {
1743
+ nextViewStates.set(
1744
+ this.remapTreePath(path, oldPath, newPath, isDirectory),
1745
+ viewState
1746
+ );
1747
+ }
1748
+ session.editorState.viewStates = nextViewStates;
1749
+ }
1750
+
1751
+ const nextSelectedTreePath = this.remapTreePath(
1752
+ session.selectedTreePath,
1753
+ oldPath,
1754
+ newPath,
1755
+ isDirectory
1756
+ );
1757
+ if (nextSelectedTreePath !== session.selectedTreePath) {
1758
+ session.selectedTreePath = nextSelectedTreePath || '';
1759
+ visualChanged = true;
1760
+ }
1761
+
1762
+ const nextEditingTreePath = this.remapTreePath(
1763
+ session.treeEditingPath,
1764
+ oldPath,
1765
+ newPath,
1766
+ isDirectory
1767
+ );
1768
+ if (nextEditingTreePath !== session.treeEditingPath) {
1769
+ session.treeEditingPath = nextEditingTreePath || '';
1770
+ }
1771
+
1772
+ const nextPendingFocusPath = this.remapTreePath(
1773
+ session.pendingTreeFocusPath,
1774
+ oldPath,
1775
+ newPath,
1776
+ isDirectory
1777
+ );
1778
+ if (nextPendingFocusPath !== session.pendingTreeFocusPath) {
1779
+ session.pendingTreeFocusPath = nextPendingFocusPath || '';
1780
+ }
1781
+
1782
+ const nextPendingRenameFocusPath = this.remapTreePath(
1783
+ session.pendingTreeRenameFocusPath,
1784
+ oldPath,
1785
+ newPath,
1786
+ isDirectory
1787
+ );
1788
+ if (
1789
+ nextPendingRenameFocusPath
1790
+ !== session.pendingTreeRenameFocusPath
1791
+ ) {
1792
+ session.pendingTreeRenameFocusPath =
1793
+ nextPendingRenameFocusPath || '';
1794
+ }
1795
+
1796
+ return {
1797
+ workspaceChanged,
1798
+ visualChanged
1799
+ };
1800
+ }
1801
+
1802
+ applyDeletedPathToSession(session, targetPath, isDirectory) {
1803
+ let workspaceChanged = false;
1804
+ let visualChanged = false;
1805
+
1806
+ const filterList = (values) => values.filter(
1807
+ (value) => !this.pathMatchesTarget(value, targetPath, isDirectory)
1808
+ );
1809
+
1810
+ const nextOpenFiles = filterList(session.editorState.openFiles);
1811
+ if (
1812
+ JSON.stringify(nextOpenFiles)
1813
+ !== JSON.stringify(session.editorState.openFiles)
1814
+ ) {
1815
+ session.editorState.openFiles = nextOpenFiles;
1816
+ session.sharedWorkspaceState.openFiles = [...nextOpenFiles];
1817
+ workspaceChanged = true;
1818
+ visualChanged = true;
1819
+ }
1820
+
1821
+ const nextExpandedPaths = filterList(
1822
+ session.sharedWorkspaceState.expandedPaths
1823
+ );
1824
+ if (
1825
+ JSON.stringify(nextExpandedPaths)
1826
+ !== JSON.stringify(session.sharedWorkspaceState.expandedPaths)
1827
+ ) {
1828
+ session.sharedWorkspaceState.expandedPaths = nextExpandedPaths;
1829
+ workspaceChanged = true;
1830
+ }
1831
+
1832
+ if (
1833
+ this.pathMatchesTarget(
1834
+ session.editorState.activeFilePath,
1835
+ targetPath,
1836
+ isDirectory
1837
+ )
1838
+ ) {
1839
+ session.editorState.activeFilePath = nextOpenFiles[0] || null;
1840
+ visualChanged = true;
1841
+ }
1842
+
1843
+ if (session.editorState.viewStates.size > 0) {
1844
+ const nextViewStates = new Map();
1845
+ let changed = false;
1846
+ for (const [path, viewState] of session.editorState.viewStates) {
1847
+ if (this.pathMatchesTarget(path, targetPath, isDirectory)) {
1848
+ changed = true;
1849
+ continue;
1850
+ }
1851
+ nextViewStates.set(path, viewState);
1852
+ }
1853
+ if (changed) {
1854
+ session.editorState.viewStates = nextViewStates;
1855
+ }
1856
+ }
1857
+
1858
+ if (
1859
+ this.pathMatchesTarget(
1860
+ session.selectedTreePath,
1861
+ targetPath,
1862
+ isDirectory
1863
+ )
1864
+ ) {
1865
+ session.selectedTreePath = '';
1866
+ visualChanged = true;
1867
+ }
1868
+
1869
+ if (
1870
+ this.pathMatchesTarget(
1871
+ session.treeEditingPath,
1872
+ targetPath,
1873
+ isDirectory
1874
+ )
1875
+ ) {
1876
+ session.treeEditingPath = '';
1877
+ }
1878
+
1879
+ if (
1880
+ this.pathMatchesTarget(
1881
+ session.pendingTreeFocusPath,
1882
+ targetPath,
1883
+ isDirectory
1884
+ )
1885
+ ) {
1886
+ session.pendingTreeFocusPath = '';
1887
+ }
1888
+
1889
+ if (
1890
+ this.pathMatchesTarget(
1891
+ session.pendingTreeRenameFocusPath,
1892
+ targetPath,
1893
+ isDirectory
1894
+ )
1895
+ ) {
1896
+ session.pendingTreeRenameFocusPath = '';
1897
+ }
1898
+
1899
+ const activeTabKey = session.workspaceState.activeTabKey || '';
1900
+ if (
1901
+ isFileWorkspaceTabKey(activeTabKey)
1902
+ && this.pathMatchesTarget(
1903
+ workspaceKeyToFilePath(activeTabKey),
1904
+ targetPath,
1905
+ isDirectory
1906
+ )
1907
+ ) {
1908
+ session.workspaceState.activeTabKey = '';
1909
+ visualChanged = true;
1910
+ }
1911
+
1912
+ const lastNonTerminal = session.workspaceState.lastNonTerminalTabKey || '';
1913
+ if (
1914
+ isFileWorkspaceTabKey(lastNonTerminal)
1915
+ && this.pathMatchesTarget(
1916
+ workspaceKeyToFilePath(lastNonTerminal),
1917
+ targetPath,
1918
+ isDirectory
1919
+ )
1920
+ ) {
1921
+ session.workspaceState.lastNonTerminalTabKey = '';
1922
+ }
1923
+
1924
+ return {
1925
+ workspaceChanged,
1926
+ visualChanged
1927
+ };
1928
+ }
1929
+
1930
+ focusTreePath(session, path) {
1931
+ if (!session?.fileTreeElement || !path) return;
1932
+ requestAnimationFrame(() => {
1933
+ const item = Array.from(
1934
+ session.fileTreeElement.querySelectorAll('li')
1935
+ ).find((candidate) => candidate.dataset.path === path);
1936
+ const row = item?.querySelector('.file-tree-item');
1937
+ if (row) {
1938
+ row.scrollIntoView({ block: 'nearest' });
1939
+ session.fileTreeElement.focus({ preventScroll: true });
1940
+ }
1941
+ });
1942
+ }
1943
+
1944
+ keepTreeFocus(session) {
1945
+ if (!session?.fileTreeElement || session.treeEditingPath) {
1946
+ return;
1947
+ }
1948
+ requestAnimationFrame(() => {
1949
+ if (!session?.fileTreeElement || session.treeEditingPath) {
1950
+ return;
1951
+ }
1952
+ session.fileTreeElement.focus({ preventScroll: true });
1953
+ });
1954
+ }
1955
+
1956
+ handleRenamedPaths(server, oldPath, newPath, isDirectory) {
1957
+ this.remapModelStorePaths(server, oldPath, newPath, isDirectory);
1958
+
1959
+ let currentSessionAffected = false;
1960
+ for (const session of state.sessions.values()) {
1961
+ if (session.serverId !== server.id) continue;
1962
+
1963
+ const { workspaceChanged, visualChanged } =
1964
+ this.applyRenamedPathToSession(
1965
+ session,
1966
+ oldPath,
1967
+ newPath,
1968
+ isDirectory
1969
+ );
1970
+ const pendingChanged = this.remapPendingFileWrites(
1971
+ session.key,
1972
+ oldPath,
1973
+ newPath,
1974
+ isDirectory
1975
+ );
1976
+
1977
+ if (workspaceChanged || pendingChanged) {
1978
+ session.saveState({ touchWorkspace: true });
1979
+ }
1980
+
1981
+ if (visualChanged && session.key === state.activeSessionKey) {
1982
+ currentSessionAffected = true;
1983
+ }
1984
+
1985
+ if (session.editorState.isVisible) {
1986
+ this.requestSessionTreeRefresh(session);
1987
+ }
1988
+ }
1989
+
1990
+ if (!currentSessionAffected || !this.currentSession) {
1991
+ return;
1992
+ }
1993
+
1994
+ this.renderEditorTabs();
1995
+ this.updateEditorPaneVisibility();
1996
+ const activeKey = this.getActiveWorkspaceTabKey(this.currentSession);
1997
+ if (isFileWorkspaceTabKey(activeKey)) {
1998
+ this.activateFileTab(
1999
+ workspaceKeyToFilePath(activeKey),
2000
+ true,
2001
+ { focusEditor: false }
2002
+ );
2003
+ return;
2004
+ }
2005
+ if (isAgentWorkspaceTabKey(activeKey)) {
2006
+ this.activateAgentTab(activeKey, true);
2007
+ return;
2008
+ }
2009
+ if (isTerminalWorkspaceTabKey(activeKey)) {
2010
+ this.activateTerminalTab(true);
2011
+ }
2012
+ }
2013
+
2014
+ handleDeletedPaths(server, targetPath, isDirectory) {
2015
+ this.removeDeletedModelStorePaths(server, targetPath, isDirectory);
2016
+
2017
+ let currentSessionAffected = false;
2018
+ for (const session of state.sessions.values()) {
2019
+ if (session.serverId !== server.id) continue;
2020
+
2021
+ const { workspaceChanged, visualChanged } =
2022
+ this.applyDeletedPathToSession(
2023
+ session,
2024
+ targetPath,
2025
+ isDirectory
2026
+ );
2027
+ const pendingChanged = this.removeDeletedPendingFileWrites(
2028
+ session.key,
2029
+ targetPath,
2030
+ isDirectory
2031
+ );
2032
+
2033
+ if (workspaceChanged || pendingChanged) {
2034
+ session.saveState({ touchWorkspace: true });
2035
+ }
2036
+
2037
+ if (visualChanged && session.key === state.activeSessionKey) {
2038
+ currentSessionAffected = true;
2039
+ }
2040
+
2041
+ if (session.editorState.isVisible) {
2042
+ this.requestSessionTreeRefresh(session);
2043
+ }
2044
+ }
2045
+
2046
+ if (!currentSessionAffected || !this.currentSession) {
2047
+ return;
2048
+ }
2049
+
2050
+ this.renderEditorTabs();
2051
+ this.updateEditorPaneVisibility();
2052
+ const activeKey = this.getActiveWorkspaceTabKey(this.currentSession);
2053
+ if (isFileWorkspaceTabKey(activeKey)) {
2054
+ this.activateFileTab(
2055
+ workspaceKeyToFilePath(activeKey),
2056
+ true,
2057
+ { focusEditor: false }
2058
+ );
2059
+ return;
2060
+ }
2061
+ if (isAgentWorkspaceTabKey(activeKey)) {
2062
+ this.activateAgentTab(activeKey, true);
2063
+ return;
2064
+ }
2065
+ if (isTerminalWorkspaceTabKey(activeKey)) {
2066
+ this.activateTerminalTab(true);
2067
+ return;
2068
+ }
2069
+ this.showEmptyState();
2070
+ }
2071
+
2072
+ async loadIconMap() {
2073
+ try {
2074
+ const res = await fetch('/icons/map.json');
2075
+ this.iconMap = await res.json();
2076
+ } catch (e) {
2077
+ console.error('Failed to load icon map', e);
2078
+ }
2079
+ }
2080
+
2081
+ getIcon(name, isDirectory, isExpanded) {
2082
+ if (!this.iconMap) return isDirectory ? (isExpanded ? '📂' : '📁') : '📄';
2083
+
2084
+ if (isDirectory) {
2085
+ const folderIcon = isExpanded ? (this.iconMap.folderOpen || 'folder-src-open') : (this.iconMap.folder || 'folder-src');
2086
+ return `<img src="/icons/${folderIcon}.svg" class="file-icon" alt="folder">`;
2087
+ }
2088
+
2089
+ const lowerName = name.toLowerCase();
2090
+ if (this.iconMap.filenames[lowerName]) {
2091
+ return `<img src="/icons/${this.iconMap.filenames[lowerName]}.svg" class="file-icon" alt="file">`;
2092
+ }
2093
+
2094
+ const parts = name.split('.');
2095
+ if (parts.length > 1) {
2096
+ const ext = parts.pop().toLowerCase();
2097
+ if (this.iconMap.extensions[ext]) {
2098
+ return `<img src="/icons/${this.iconMap.extensions[ext]}.svg" class="file-icon" alt="file">`;
2099
+ }
2100
+ }
2101
+
2102
+ return `<img src="/icons/${this.iconMap.default || 'document'}.svg" class="file-icon" alt="file">`;
2103
+ }
2104
+
2105
+ initResizer() {
2106
+ let startY, startHeight;
2107
+ const onMouseMove = (e) => {
2108
+ const dy = e.clientY - startY;
2109
+ const newHeight = startHeight + dy;
2110
+ const containerHeight = this.pane.parentElement.clientHeight;
2111
+ const resizerHeight = this.resizer.offsetHeight;
2112
+
2113
+ if (newHeight > 100 && newHeight < containerHeight - resizerHeight - 50) {
2114
+ const flex = `0 0 ${newHeight}px`;
2115
+ this.pane.style.flex = flex;
2116
+ if (this.currentSession) {
2117
+ this.currentSession.layoutState.editorFlex = flex;
2118
+ }
2119
+ this.layout();
2120
+ }
2121
+ };
2122
+ const onMouseUp = () => {
2123
+ document.removeEventListener('mousemove', onMouseMove);
2124
+ document.removeEventListener('mouseup', onMouseUp);
2125
+ document.body.style.cursor = '';
2126
+ const termWrapper = document.getElementById('terminal-wrapper');
2127
+ if (termWrapper) termWrapper.style.pointerEvents = '';
2128
+ };
2129
+ this.resizer.addEventListener('mousedown', (e) => {
2130
+ startY = e.clientY;
2131
+ startHeight = this.pane.offsetHeight;
2132
+ document.addEventListener('mousemove', onMouseMove);
2133
+ document.addEventListener('mouseup', onMouseUp);
2134
+ document.body.style.cursor = 'row-resize';
2135
+ const termWrapper = document.getElementById('terminal-wrapper');
2136
+ if (termWrapper) termWrapper.style.pointerEvents = 'none';
2137
+ });
2138
+ }
2139
+
2140
+ refreshSessionTree(session) {
2141
+ if (!session || !session.fileTreeElement) return;
2142
+ session.fileTreeRenderToken = (session.fileTreeRenderToken || 0) + 1;
2143
+ const renderToken = session.fileTreeRenderToken;
2144
+ const scrollTop = session.fileTreeElement.scrollTop;
2145
+ void this.renderTree(
2146
+ session.cwd,
2147
+ session.fileTreeElement,
2148
+ session,
2149
+ renderToken
2150
+ ).finally(() => {
2151
+ if (
2152
+ session.fileTreeElement
2153
+ && session.fileTreeRenderToken === renderToken
2154
+ ) {
2155
+ session.fileTreeElement.scrollTop = scrollTop;
2156
+ }
2157
+ });
2158
+ this.updateTreeAutoRefresh();
2159
+ }
2160
+
2161
+ isSessionTreeVisible(session) {
2162
+ return !!session?.fileTreeElement && !!session?.editorState?.isVisible;
2163
+ }
2164
+
2165
+ canRefreshSessionTree(session) {
2166
+ return this.isSessionTreeVisible(session) && !session.treeEditingPath;
2167
+ }
2168
+
2169
+ refreshVisibleSessionTrees() {
2170
+ for (const session of state.sessions.values()) {
2171
+ if (this.canRefreshSessionTree(session)) {
2172
+ this.requestSessionTreeRefresh(session);
2173
+ }
2174
+ }
2175
+ }
2176
+
2177
+ requestSessionTreeRefresh(session, { force = false } = {}) {
2178
+ if (!force && !this.canRefreshSessionTree(session)) {
2179
+ this.updateTreeAutoRefresh();
2180
+ return;
2181
+ }
2182
+ if (session.fileTreeRefreshQueued) return;
2183
+ session.fileTreeRefreshQueued = true;
2184
+ requestAnimationFrame(() => {
2185
+ session.fileTreeRefreshQueued = false;
2186
+ if (force || this.canRefreshSessionTree(session)) {
2187
+ this.refreshSessionTree(session);
2188
+ } else {
2189
+ this.updateTreeAutoRefresh();
2190
+ }
2191
+ });
2192
+ }
2193
+
2194
+ updateTreeAutoRefresh() {
2195
+ const shouldRun = (
2196
+ document.visibilityState === 'visible'
2197
+ && Array.from(state.sessions.values()).some(
2198
+ (session) => this.canRefreshSessionTree(session)
2199
+ )
2200
+ );
2201
+ if (shouldRun && !this.treeRefreshTimer) {
2202
+ this.treeRefreshTimer = window.setInterval(() => {
2203
+ if (document.visibilityState !== 'visible') {
2204
+ this.updateTreeAutoRefresh();
2205
+ return;
2206
+ }
2207
+ const hasVisibleTrees = Array.from(
2208
+ state.sessions.values()
2209
+ ).some((session) => this.canRefreshSessionTree(session));
2210
+ if (!hasVisibleTrees) {
2211
+ this.updateTreeAutoRefresh();
2212
+ return;
2213
+ }
2214
+ this.refreshVisibleSessionTrees();
2215
+ }, FILE_TREE_REFRESH_INTERVAL_MS);
2216
+ return;
2217
+ }
2218
+ if (!shouldRun && this.treeRefreshTimer) {
2219
+ window.clearInterval(this.treeRefreshTimer);
2220
+ this.treeRefreshTimer = null;
2221
+ }
2222
+ }
2223
+
2224
+ setSelectedTreePath(session, path, { preserveFocus = false } = {}) {
2225
+ if (!session) return;
2226
+ const nextPath = typeof path === 'string' ? path : '';
2227
+ if (session.selectedTreePath === nextPath) return;
2228
+ session.selectedTreePath = nextPath;
2229
+ if (preserveFocus && nextPath) {
2230
+ session.pendingTreeFocusPath = nextPath;
2231
+ }
2232
+ if (this.isSessionTreeVisible(session)) {
2233
+ this.syncSelectedTreePath(session);
2234
+ }
2235
+ }
2236
+
2237
+ syncSelectedTreePath(session) {
2238
+ if (!session?.fileTreeElement) return;
2239
+ const selectedPath = session.selectedTreePath || '';
2240
+ Array.from(
2241
+ session.fileTreeElement.querySelectorAll('.file-tree-item')
2242
+ ).forEach((row) => {
2243
+ const rowPath = row.parentElement?.dataset.path || '';
2244
+ row.classList.toggle(
2245
+ 'selected',
2246
+ selectedPath.length > 0 && rowPath === selectedPath
2247
+ );
2248
+ });
2249
+ }
2250
+
2251
+ getVisibleTreeRows(session) {
2252
+ if (!session?.fileTreeElement) return [];
2253
+ return Array.from(
2254
+ session.fileTreeElement.querySelectorAll('li > .file-tree-item')
2255
+ ).filter((row) => row instanceof HTMLElement);
2256
+ }
2257
+
2258
+ getDomSelectedTreePath(session) {
2259
+ return session?.fileTreeElement?.querySelector(
2260
+ '.file-tree-item.selected'
2261
+ )?.parentElement?.dataset.path || '';
2262
+ }
2263
+
2264
+ moveTreeSelection(session, delta) {
2265
+ if (!session || !delta) return false;
2266
+ const rows = this.getVisibleTreeRows(session);
2267
+ if (rows.length === 0) return false;
2268
+
2269
+ const currentPath = this.getDomSelectedTreePath(session)
2270
+ || session.selectedTreePath
2271
+ || session.editorState.activeFilePath
2272
+ || '';
2273
+ let currentIndex = rows.findIndex(
2274
+ (row) => row.parentElement?.dataset.path === currentPath
2275
+ );
2276
+ if (currentIndex === -1) {
2277
+ currentIndex = delta > 0 ? -1 : rows.length;
2278
+ }
2279
+
2280
+ const nextIndex = Math.max(
2281
+ 0,
2282
+ Math.min(rows.length - 1, currentIndex + delta)
2283
+ );
2284
+ const nextRow = rows[nextIndex];
2285
+ const nextPath = nextRow?.parentElement?.dataset.path || '';
2286
+ if (!nextPath) return false;
2287
+
2288
+ this.setSelectedTreePath(session, nextPath, { preserveFocus: true });
2289
+ nextRow.scrollIntoView({ block: 'nearest' });
2290
+ session.fileTreeElement?.focus({ preventScroll: true });
2291
+ return true;
2292
+ }
2293
+
2294
+ beginSelectedTreeRename(session) {
2295
+ if (!session) return false;
2296
+ const selectedPath = this.getDomSelectedTreePath(session)
2297
+ || session.selectedTreePath
2298
+ || '';
2299
+ if (!selectedPath) return false;
2300
+
2301
+ const item = session.fileTreeElement?.querySelector(
2302
+ `li[data-path="${CSS.escape(selectedPath)}"]`
2303
+ );
2304
+ const row = item?.querySelector('.file-tree-item');
2305
+ const nameEl = row?.querySelector('.file-tree-name');
2306
+ if (
2307
+ !item
2308
+ || !row
2309
+ || !nameEl
2310
+ || item.dataset.renameable !== '1'
2311
+ ) {
2312
+ return false;
2313
+ }
2314
+
2315
+ const renameButton = row.querySelector('.file-tree-rename-btn');
2316
+ if (
2317
+ renameButton instanceof HTMLButtonElement
2318
+ && !renameButton.disabled
2319
+ ) {
2320
+ renameButton.click();
2321
+ return true;
2322
+ }
2323
+
2324
+ this.beginTreeRename(session, {
2325
+ path: selectedPath,
2326
+ name: nameEl.textContent || '',
2327
+ isDirectory: item.dataset.isDirectory === '1',
2328
+ renameable: true
2329
+ });
2330
+ return true;
2331
+ }
2332
+
2333
+ async deleteSelectedTreeEntry(session) {
2334
+ if (!session) return false;
2335
+ const selectedPath = this.getDomSelectedTreePath(session)
2336
+ || session.selectedTreePath
2337
+ || '';
2338
+ if (!selectedPath) return false;
2339
+
2340
+ const item = session.fileTreeElement?.querySelector(
2341
+ `li[data-path="${CSS.escape(selectedPath)}"]`
2342
+ );
2343
+ const row = item?.querySelector('.file-tree-item');
2344
+ const nameEl = row?.querySelector('.file-tree-name');
2345
+ if (
2346
+ !item
2347
+ || !row
2348
+ || !nameEl
2349
+ || item.dataset.deleteable !== '1'
2350
+ ) {
2351
+ return false;
2352
+ }
2353
+
2354
+ await this.deleteTreeEntry(session, {
2355
+ path: selectedPath,
2356
+ name: nameEl.textContent || '',
2357
+ isDirectory: item.dataset.isDirectory === '1',
2358
+ deleteable: true
2359
+ });
2360
+ return true;
2361
+ }
2362
+
2363
+ async createTreeEntry(session, parentPath, kind) {
2364
+ if (!session || typeof parentPath !== 'string' || !parentPath) {
2365
+ return;
2366
+ }
2367
+
2368
+ try {
2369
+ const response = await session.server.fetch('/api/fs/create', {
2370
+ method: 'POST',
2371
+ headers: { 'Content-Type': 'application/json' },
2372
+ body: JSON.stringify({
2373
+ parentPath,
2374
+ kind
2375
+ })
2376
+ });
2377
+ if (!response.ok) {
2378
+ await throwResponseError(response, 'Failed to create path');
2379
+ }
2380
+
2381
+ const payload = await response.json();
2382
+ if (
2383
+ parentPath !== '.'
2384
+ && !session.sharedWorkspaceState.expandedPaths.includes(parentPath)
2385
+ ) {
2386
+ session.sharedWorkspaceState.expandedPaths =
2387
+ uniqueStringList([
2388
+ ...session.sharedWorkspaceState.expandedPaths,
2389
+ parentPath
2390
+ ]);
2391
+ session.saveState({ touchWorkspace: true });
2392
+ void session.server.fetch('/api/memory/expand', {
2393
+ method: 'POST',
2394
+ headers: { 'Content-Type': 'application/json' },
2395
+ body: JSON.stringify({
2396
+ path: parentPath,
2397
+ expanded: true
2398
+ })
2399
+ });
2400
+ }
2401
+
2402
+ this.beginTreeRename(session, {
2403
+ path: payload.path,
2404
+ name: payload.name,
2405
+ isDirectory: !!payload.isDirectory,
2406
+ renameable: true
2407
+ });
2408
+ } catch (error) {
2409
+ alert(error.message || 'Failed to create path', {
2410
+ type: 'error',
2411
+ title: 'Files'
2412
+ });
2413
+ }
2414
+ }
2415
+
2416
+ cancelTreeRename(session) {
2417
+ if (!session || !session.treeEditingPath) return;
2418
+ session.treeEditingPath = '';
2419
+ session.treeRenameSubmitting = false;
2420
+ session.pendingTreeRenameFocusPath = '';
2421
+ if (this.isSessionTreeVisible(session)) {
2422
+ this.requestSessionTreeRefresh(session);
2423
+ }
2424
+ }
2425
+
2426
+ beginTreeRename(session, file) {
2427
+ if (!session || !file?.renameable) return;
2428
+ session.selectedTreePath = file.path;
2429
+ session.pendingTreeFocusPath = '';
2430
+ session.treeEditingPath = file.path;
2431
+ session.treeRenameSubmitting = false;
2432
+ session.pendingTreeRenameFocusPath = file.path;
2433
+ this.requestSessionTreeRefresh(session, { force: true });
2434
+ }
2435
+
2436
+ async deleteTreeEntry(session, file) {
2437
+ if (!session || !file?.deleteable) {
2438
+ return;
2439
+ }
2440
+ const confirmed = await showConfirmModal({
2441
+ title: file.isDirectory
2442
+ ? '⚠️ Delete Folder'
2443
+ : '⚠️ Delete File',
2444
+ message: file.isDirectory
2445
+ ? `Delete folder "${file.name}" and all of its contents?`
2446
+ : `Delete file "${file.name}"?`,
2447
+ note: 'ℹ️ Deleted items do not go to the Trash.',
2448
+ confirmLabel: 'Delete',
2449
+ danger: true,
2450
+ returnFocus: session.fileTreeElement
2451
+ });
2452
+ if (!confirmed) {
2453
+ session.fileTreeElement?.focus({ preventScroll: true });
2454
+ return;
2455
+ }
2456
+
2457
+ try {
2458
+ const response = await session.server.fetch('/api/fs/delete', {
2459
+ method: 'POST',
2460
+ headers: { 'Content-Type': 'application/json' },
2461
+ body: JSON.stringify({
2462
+ path: file.path
2463
+ })
2464
+ });
2465
+ if (!response.ok) {
2466
+ await throwResponseError(response, 'Failed to delete path');
2467
+ }
2468
+ const payload = await response.json();
2469
+ session.selectedTreePath = '';
2470
+ session.pendingTreeFocusPath = '';
2471
+ session.pendingTreeRenameFocusPath = '';
2472
+ session.treeEditingPath = '';
2473
+ this.handleDeletedPaths(
2474
+ session.server,
2475
+ payload.path || file.path,
2476
+ !!payload.isDirectory
2477
+ );
2478
+ this.requestSessionTreeRefresh(session);
2479
+ session.fileTreeElement?.focus({ preventScroll: true });
2480
+ } catch (error) {
2481
+ alert(error.message || 'Failed to delete path', {
2482
+ type: 'error',
2483
+ title: 'Files'
2484
+ });
2485
+ }
2486
+ }
2487
+
2488
+ async commitTreeRename(session, file, nextName) {
2489
+ if (!session || !file || typeof nextName !== 'string') {
2490
+ return;
2491
+ }
2492
+ if (nextName.length === 0) {
2493
+ return;
2494
+ }
2495
+ if (nextName === file.name) {
2496
+ this.cancelTreeRename(session);
2497
+ this.focusTreePath(session, file.path);
2498
+ return;
2499
+ }
2500
+
2501
+ session.treeRenameSubmitting = true;
2502
+ try {
2503
+ const response = await session.server.fetch('/api/fs/rename', {
2504
+ method: 'POST',
2505
+ headers: { 'Content-Type': 'application/json' },
2506
+ body: JSON.stringify({
2507
+ path: file.path,
2508
+ newName: nextName
2509
+ })
2510
+ });
2511
+ if (!response.ok) {
2512
+ if (response.status === 409) {
2513
+ let message = 'A file or folder with that name already exists.';
2514
+ try {
2515
+ const payload = await response.json();
2516
+ if (payload?.error) {
2517
+ message = payload.error;
2518
+ }
2519
+ } catch {
2520
+ // Ignore invalid JSON error bodies.
2521
+ }
2522
+ await showConfirmModal({
2523
+ title: 'Rename Failed',
2524
+ message,
2525
+ confirmLabel: 'OK',
2526
+ hideCancel: true
2527
+ });
2528
+ session.treeRenameSubmitting = false;
2529
+ requestAnimationFrame(() => {
2530
+ const renameInput = session.fileTreeElement?.querySelector(
2531
+ '.file-tree-rename-input'
2532
+ );
2533
+ if (renameInput instanceof HTMLInputElement) {
2534
+ renameInput.focus({ preventScroll: true });
2535
+ renameInput.setSelectionRange(
2536
+ 0,
2537
+ renameInput.value.length
2538
+ );
2539
+ }
2540
+ });
2541
+ return;
2542
+ }
2543
+ await throwResponseError(response, 'Failed to rename path');
2544
+ }
2545
+ const payload = await response.json();
2546
+ session.treeEditingPath = '';
2547
+ session.treeRenameSubmitting = false;
2548
+ session.pendingTreeRenameFocusPath = '';
2549
+ session.selectedTreePath = payload.newPath || file.path;
2550
+ session.pendingTreeFocusPath = payload.newPath || file.path;
2551
+ this.handleRenamedPaths(
2552
+ session.server,
2553
+ file.path,
2554
+ payload.newPath || file.path,
2555
+ !!payload.isDirectory
2556
+ );
2557
+ this.requestSessionTreeRefresh(session);
2558
+ this.focusTreePath(session, session.pendingTreeFocusPath);
2559
+ } catch (error) {
2560
+ session.treeRenameSubmitting = false;
2561
+ this.cancelTreeRename(session);
2562
+ alert(error.message || 'Failed to rename path', {
2563
+ type: 'error',
2564
+ title: 'Files'
2565
+ });
2566
+ }
2567
+ }
2568
+
2569
+ ensureTreeList(container) {
2570
+ const existing = Array.from(container.children).find(
2571
+ (child) => child.tagName === 'UL'
2572
+ );
2573
+ if (existing) return existing;
2574
+ const list = document.createElement('ul');
2575
+ container.appendChild(list);
2576
+ return list;
2577
+ }
2578
+
2579
+ getTreeChildList(item) {
2580
+ return Array.from(item.children).find((child) => child.tagName === 'UL')
2581
+ || null;
2582
+ }
2583
+
2584
+ getTreeItemExpanded(filePath, session) {
2585
+ return session.sharedWorkspaceState.expandedPaths.includes(filePath);
2586
+ }
2587
+
2588
+ updateTreeCreateRow(list, dirPath, creatable, session) {
2589
+ let row = Array.from(list.children).find(
2590
+ (child) => child.classList?.contains('file-tree-create-entry')
2591
+ );
2592
+
2593
+ if (!creatable) {
2594
+ row?.remove();
2595
+ return;
2596
+ }
2597
+
2598
+ if (!row) {
2599
+ row = document.createElement('li');
2600
+ row.className = 'file-tree-create-entry';
2601
+
2602
+ const actions = document.createElement('div');
2603
+ actions.className = 'file-tree-create-actions';
2604
+
2605
+ const newFolderButton = document.createElement('button');
2606
+ newFolderButton.type = 'button';
2607
+ newFolderButton.className = 'file-tree-new-folder-btn';
2608
+ newFolderButton.title = 'New Folder';
2609
+ newFolderButton.innerHTML = NEW_FOLDER_ICON_SVG;
2610
+ actions.appendChild(newFolderButton);
2611
+
2612
+ const newFileButton = document.createElement('button');
2613
+ newFileButton.type = 'button';
2614
+ newFileButton.className = 'file-tree-new-file-btn';
2615
+ newFileButton.title = 'New File';
2616
+ newFileButton.innerHTML = NEW_FILE_ICON_SVG;
2617
+ actions.appendChild(newFileButton);
2618
+
2619
+ row.appendChild(actions);
2620
+ }
2621
+
2622
+ const newFolderButton = row.querySelector('.file-tree-new-folder-btn');
2623
+ const newFileButton = row.querySelector('.file-tree-new-file-btn');
2624
+
2625
+ if (newFolderButton instanceof HTMLButtonElement) {
2626
+ newFolderButton.setAttribute(
2627
+ 'aria-label',
2628
+ `New folder in ${dirPath}`
2629
+ );
2630
+ newFolderButton.onmousedown = (event) => {
2631
+ event.preventDefault();
2632
+ event.stopPropagation();
2633
+ };
2634
+ newFolderButton.onclick = (event) => {
2635
+ event.stopPropagation();
2636
+ void this.createTreeEntry(session, dirPath, 'directory');
2637
+ };
2638
+ }
2639
+
2640
+ if (newFileButton instanceof HTMLButtonElement) {
2641
+ newFileButton.setAttribute('aria-label', `New file in ${dirPath}`);
2642
+ newFileButton.onmousedown = (event) => {
2643
+ event.preventDefault();
2644
+ event.stopPropagation();
2645
+ };
2646
+ newFileButton.onclick = (event) => {
2647
+ event.stopPropagation();
2648
+ void this.createTreeEntry(session, dirPath, 'file');
2649
+ };
2650
+ }
2651
+
2652
+ list.appendChild(row);
2653
+ }
2654
+
2655
+ updateTreeItem(li, file, session, renderToken) {
2656
+ li.dataset.path = file.path;
2657
+ li.dataset.isDirectory = file.isDirectory ? '1' : '0';
2658
+ li.dataset.renameable = file.renameable ? '1' : '0';
2659
+ li.dataset.deleteable = file.deleteable ? '1' : '0';
2660
+
2661
+ let row = Array.from(li.children).find(
2662
+ (child) => child.classList?.contains('file-tree-item')
2663
+ );
2664
+ if (!row) {
2665
+ row = document.createElement('div');
2666
+ row.className = 'file-tree-item';
2667
+ li.prepend(row);
2668
+ }
2669
+ row.tabIndex = -1;
2670
+
2671
+ let icon = row.querySelector('.icon');
2672
+ if (!icon) {
2673
+ icon = document.createElement('span');
2674
+ icon.className = 'icon';
2675
+ row.appendChild(icon);
2676
+ }
2677
+
2678
+ let renameButton = row.querySelector('.file-tree-rename-btn');
2679
+ if (!renameButton) {
2680
+ renameButton = document.createElement('button');
2681
+ renameButton.type = 'button';
2682
+ renameButton.className = 'file-tree-rename-btn';
2683
+ renameButton.title = 'Rename';
2684
+ renameButton.setAttribute('aria-label', `Rename ${file.name}`);
2685
+ renameButton.innerHTML = RENAME_ICON_SVG;
2686
+ row.appendChild(renameButton);
2687
+ }
2688
+
2689
+ let deleteButton = row.querySelector('.file-tree-delete-btn');
2690
+ if (!deleteButton) {
2691
+ deleteButton = document.createElement('button');
2692
+ deleteButton.type = 'button';
2693
+ deleteButton.className = 'file-tree-delete-btn';
2694
+ deleteButton.title = 'Delete';
2695
+ deleteButton.setAttribute('aria-label', `Delete ${file.name}`);
2696
+ deleteButton.innerHTML = DELETE_ICON_SVG;
2697
+ row.appendChild(deleteButton);
2698
+ }
2699
+
2700
+ let name = row.querySelector('.file-tree-name');
2701
+ if (!name) {
2702
+ name = document.createElement('span');
2703
+ name.className = 'file-tree-name';
2704
+ row.appendChild(name);
2705
+ }
2706
+
2707
+ let renameInput = row.querySelector('.file-tree-rename-input');
2708
+ const isEditing = session.treeEditingPath === file.path;
2709
+ if (isEditing && !renameInput) {
2710
+ renameInput = document.createElement('input');
2711
+ renameInput.type = 'text';
2712
+ renameInput.className = 'file-tree-rename-input';
2713
+ row.appendChild(renameInput);
2714
+ } else if (!isEditing && renameInput) {
2715
+ renameInput.remove();
2716
+ renameInput = null;
2717
+ }
2718
+
2719
+ row.className = 'file-tree-item';
2720
+ if (file.isDirectory) {
2721
+ row.classList.add('is-dir');
2722
+ }
2723
+ row.classList.toggle(
2724
+ 'active',
2725
+ !file.isDirectory
2726
+ && session.editorState.activeFilePath === file.path
2727
+ );
2728
+ row.classList.toggle(
2729
+ 'selected',
2730
+ session.selectedTreePath === file.path
2731
+ );
2732
+ row.classList.toggle('editing', isEditing);
2733
+
2734
+ const isExpanded = file.isDirectory
2735
+ && this.getTreeItemExpanded(file.path, session);
2736
+ li.classList.toggle('expanded', isExpanded);
2737
+ icon.innerHTML = this.getIcon(file.name, file.isDirectory, isExpanded);
2738
+ name.textContent = file.name;
2739
+ name.style.display = isEditing ? 'none' : '';
2740
+ renameButton.style.display = isEditing ? 'none' : '';
2741
+ deleteButton.style.display = isEditing ? 'none' : '';
2742
+ renameButton.hidden = !file.renameable;
2743
+ renameButton.disabled = !file.renameable;
2744
+ renameButton.title = `Rename ${file.name}`;
2745
+ renameButton.setAttribute('aria-label', `Rename ${file.name}`);
2746
+ renameButton.onmousedown = (event) => {
2747
+ event.preventDefault();
2748
+ event.stopPropagation();
2749
+ };
2750
+ renameButton.onclick = (event) => {
2751
+ event.stopPropagation();
2752
+ this.beginTreeRename(session, file);
2753
+ };
2754
+
2755
+ deleteButton.hidden = !file.deleteable;
2756
+ deleteButton.disabled = !file.deleteable;
2757
+ deleteButton.title = `Delete ${file.name}`;
2758
+ deleteButton.setAttribute('aria-label', `Delete ${file.name}`);
2759
+ deleteButton.onmousedown = (event) => {
2760
+ event.preventDefault();
2761
+ event.stopPropagation();
2762
+ };
2763
+ deleteButton.onclick = (event) => {
2764
+ event.stopPropagation();
2765
+ void this.deleteTreeEntry(session, file);
2766
+ };
2767
+
2768
+ if (renameInput) {
2769
+ if (document.activeElement !== renameInput) {
2770
+ renameInput.value = file.name;
2771
+ }
2772
+ renameInput.onkeydown = async (event) => {
2773
+ if (event.key === 'Escape') {
2774
+ event.preventDefault();
2775
+ event.stopPropagation();
2776
+ this.cancelTreeRename(session);
2777
+ this.focusTreePath(session, file.path);
2778
+ return;
2779
+ }
2780
+ if (event.key === 'Enter') {
2781
+ event.preventDefault();
2782
+ event.stopPropagation();
2783
+ await this.commitTreeRename(
2784
+ session,
2785
+ file,
2786
+ renameInput.value
2787
+ );
2788
+ }
2789
+ };
2790
+ renameInput.onmousedown = (event) => {
2791
+ event.stopPropagation();
2792
+ };
2793
+ renameInput.onclick = (event) => {
2794
+ event.stopPropagation();
2795
+ };
2796
+ renameInput.onfocus = (event) => {
2797
+ event.stopPropagation();
2798
+ };
2799
+ renameInput.onblur = () => {
2800
+ if (!session.treeRenameSubmitting) {
2801
+ this.cancelTreeRename(session);
2802
+ }
2803
+ };
2804
+
2805
+ if (session.pendingTreeRenameFocusPath === file.path) {
2806
+ session.pendingTreeRenameFocusPath = '';
2807
+ requestAnimationFrame(() => {
2808
+ renameInput.focus({ preventScroll: true });
2809
+ renameInput.setSelectionRange(
2810
+ 0,
2811
+ renameInput.value.length
2812
+ );
2813
+ });
2814
+ }
2815
+ }
2816
+
2817
+ row.onclick = async (e) => {
2818
+ e.stopPropagation();
2819
+ if (e.target.closest('.file-tree-rename-btn')) {
2820
+ return;
2821
+ }
2822
+ if (e.target.closest('.file-tree-delete-btn')) {
2823
+ return;
2824
+ }
2825
+ if (e.target.closest('.file-tree-rename-input')) {
2826
+ return;
2827
+ }
2828
+ this.setSelectedTreePath(session, file.path, {
2829
+ preserveFocus: true
2830
+ });
2831
+ session.fileTreeElement?.focus({ preventScroll: true });
2832
+ if (file.isDirectory) {
2833
+ if (li.classList.contains('expanded')) {
2834
+ li.classList.remove('expanded');
2835
+ session.sharedWorkspaceState.expandedPaths =
2836
+ session.sharedWorkspaceState.expandedPaths
2837
+ .filter((path) => path !== file.path);
2838
+ session.saveState({ touchWorkspace: true });
2839
+ void session.server.fetch('/api/memory/expand', {
2840
+ method: 'POST',
2841
+ headers: { 'Content-Type': 'application/json' },
2842
+ body: JSON.stringify({
2843
+ path: file.path,
2844
+ expanded: false
2845
+ })
2846
+ });
2847
+ icon.innerHTML = this.getIcon(file.name, true, false);
2848
+ const childUl = this.getTreeChildList(li);
2849
+ if (childUl) {
2850
+ childUl.remove();
2851
+ }
2852
+ this.updateTreeAutoRefresh();
2853
+ return;
2854
+ }
2855
+
2856
+ li.classList.add('expanded');
2857
+ session.sharedWorkspaceState.expandedPaths =
2858
+ uniqueStringList([
2859
+ ...session.sharedWorkspaceState.expandedPaths,
2860
+ file.path
2861
+ ]);
2862
+ session.saveState({ touchWorkspace: true });
2863
+ void session.server.fetch('/api/memory/expand', {
2864
+ method: 'POST',
2865
+ headers: { 'Content-Type': 'application/json' },
2866
+ body: JSON.stringify({
2867
+ path: file.path,
2868
+ expanded: true
2869
+ })
2870
+ });
1449
2871
 
1450
- setModel(filePath, value, session = this.currentSession) {
1451
- const store = this.getModelStore(session);
1452
- if (!store) return;
1453
- store.set(filePath, value);
1454
- }
2872
+ icon.innerHTML = this.getIcon(file.name, true, true);
2873
+ await this.renderTree(file.path, li, session, renderToken);
2874
+ this.updateTreeAutoRefresh();
2875
+ session.fileTreeElement?.focus({ preventScroll: true });
2876
+ return;
2877
+ }
1455
2878
 
1456
- async loadIconMap() {
1457
- try {
1458
- const res = await fetch('/icons/map.json');
1459
- this.iconMap = await res.json();
1460
- } catch (e) {
1461
- console.error('Failed to load icon map', e);
1462
- }
1463
- }
2879
+ await this.openFile(file.path, session, {
2880
+ focusEditor: false
2881
+ });
2882
+ this.focusTreePath(session, file.path);
2883
+ session.pendingTreeFocusPath = file.path;
2884
+ this.requestSessionTreeRefresh(session);
2885
+ };
1464
2886
 
1465
- getIcon(name, isDirectory, isExpanded) {
1466
- if (!this.iconMap) return isDirectory ? (isExpanded ? '📂' : '📁') : '📄';
1467
-
1468
- if (isDirectory) {
1469
- const folderIcon = isExpanded ? (this.iconMap.folderOpen || 'folder-src-open') : (this.iconMap.folder || 'folder-src');
1470
- return `<img src="/icons/${folderIcon}.svg" class="file-icon" alt="folder">`;
1471
- }
2887
+ row.onmousedown = (event) => {
2888
+ if (
2889
+ event.target.closest('.file-tree-rename-btn')
2890
+ || event.target.closest('.file-tree-delete-btn')
2891
+ || event.target.closest('.file-tree-rename-input')
2892
+ ) {
2893
+ return;
2894
+ }
2895
+ event.preventDefault();
2896
+ session.fileTreeElement?.focus({ preventScroll: true });
2897
+ };
1472
2898
 
1473
- const lowerName = name.toLowerCase();
1474
- if (this.iconMap.filenames[lowerName]) {
1475
- return `<img src="/icons/${this.iconMap.filenames[lowerName]}.svg" class="file-icon" alt="file">`;
1476
- }
2899
+ row.onkeydown = null;
1477
2900
 
1478
- const parts = name.split('.');
1479
- if (parts.length > 1) {
1480
- const ext = parts.pop().toLowerCase();
1481
- if (this.iconMap.extensions[ext]) {
1482
- return `<img src="/icons/${this.iconMap.extensions[ext]}.svg" class="file-icon" alt="file">`;
2901
+ if (!isExpanded) {
2902
+ const childUl = this.getTreeChildList(li);
2903
+ if (childUl) {
2904
+ childUl.remove();
1483
2905
  }
1484
2906
  }
1485
2907
 
1486
- return `<img src="/icons/${this.iconMap.default || 'document'}.svg" class="file-icon" alt="file">`;
2908
+ if (session.pendingTreeFocusPath === file.path) {
2909
+ session.pendingTreeFocusPath = '';
2910
+ requestAnimationFrame(() => {
2911
+ row.scrollIntoView({ block: 'nearest' });
2912
+ session.fileTreeElement?.focus({ preventScroll: true });
2913
+ });
2914
+ }
1487
2915
  }
1488
2916
 
1489
- initResizer() {
1490
- let startY, startHeight;
1491
- const onMouseMove = (e) => {
1492
- const dy = e.clientY - startY;
1493
- const newHeight = startHeight + dy;
1494
- const containerHeight = this.pane.parentElement.clientHeight;
1495
- const resizerHeight = this.resizer.offsetHeight;
1496
-
1497
- if (newHeight > 100 && newHeight < containerHeight - resizerHeight - 50) {
1498
- const flex = `0 0 ${newHeight}px`;
1499
- this.pane.style.flex = flex;
1500
- if (this.currentSession) {
1501
- this.currentSession.layoutState.editorFlex = flex;
1502
- }
1503
- this.layout();
2917
+ reconcileTreeList(list, dirPath, files, creatable, session, renderToken) {
2918
+ const existingItems = new Map();
2919
+ Array.from(list.children).forEach((child) => {
2920
+ if (child.tagName === 'LI' && child.dataset.path) {
2921
+ existingItems.set(child.dataset.path, child);
1504
2922
  }
1505
- };
1506
- const onMouseUp = () => {
1507
- document.removeEventListener('mousemove', onMouseMove);
1508
- document.removeEventListener('mouseup', onMouseUp);
1509
- document.body.style.cursor = '';
1510
- const termWrapper = document.getElementById('terminal-wrapper');
1511
- if (termWrapper) termWrapper.style.pointerEvents = '';
1512
- };
1513
- this.resizer.addEventListener('mousedown', (e) => {
1514
- startY = e.clientY;
1515
- startHeight = this.pane.offsetHeight;
1516
- document.addEventListener('mousemove', onMouseMove);
1517
- document.addEventListener('mouseup', onMouseUp);
1518
- document.body.style.cursor = 'row-resize';
1519
- const termWrapper = document.getElementById('terminal-wrapper');
1520
- if (termWrapper) termWrapper.style.pointerEvents = 'none';
1521
2923
  });
1522
- }
1523
2924
 
1524
- refreshSessionTree(session) {
1525
- if (!session || !session.fileTreeElement) return;
1526
- session.fileTreeElement.innerHTML = '';
1527
- this.renderTree(session.cwd, session.fileTreeElement, session);
2925
+ const orderedItems = [];
2926
+ for (const file of files) {
2927
+ let li = existingItems.get(file.path) || null;
2928
+ if (!li) {
2929
+ li = document.createElement('li');
2930
+ } else {
2931
+ existingItems.delete(file.path);
2932
+ }
2933
+ this.updateTreeItem(li, file, session, renderToken);
2934
+ orderedItems.push(li);
2935
+ }
2936
+
2937
+ for (const li of existingItems.values()) {
2938
+ li.remove();
2939
+ }
2940
+
2941
+ for (const li of orderedItems) {
2942
+ list.appendChild(li);
2943
+ }
2944
+
2945
+ this.updateTreeCreateRow(list, dirPath, creatable, session);
1528
2946
  }
1529
2947
 
1530
2948
  initMonaco() {
@@ -1656,6 +3074,7 @@ class EditorManager {
1656
3074
  this.updateEditorPaneVisibility();
1657
3075
  }
1658
3076
 
3077
+ this.updateTreeAutoRefresh();
1659
3078
  session.updateTabUI();
1660
3079
  session.saveState({ touchWorkspace: true });
1661
3080
  }
@@ -1694,6 +3113,7 @@ class EditorManager {
1694
3113
 
1695
3114
  this.updateEditorPaneVisibility();
1696
3115
  this.updateTerminalLayoutButton();
3116
+ this.updateTreeAutoRefresh();
1697
3117
 
1698
3118
  // Restore layout
1699
3119
  if (session.layoutState) {
@@ -1719,102 +3139,62 @@ class EditorManager {
1719
3139
  }
1720
3140
  }
1721
3141
 
1722
- async renderTree(dirPath, container, session) {
3142
+ async renderTree(
3143
+ dirPath,
3144
+ container,
3145
+ session,
3146
+ renderToken = session?.fileTreeRenderToken || 0
3147
+ ) {
1723
3148
  try {
1724
- const res = await session.server.fetch(`/api/fs/list?path=${encodeURIComponent(dirPath)}`);
3149
+ const res = await session.server.fetch(
3150
+ `/api/fs/list?path=${encodeURIComponent(dirPath)}`
3151
+ );
1725
3152
  if (!res.ok) return;
1726
- const files = await res.json();
3153
+ const payload = await res.json();
3154
+ const files = Array.isArray(payload)
3155
+ ? payload
3156
+ : Array.isArray(payload?.items)
3157
+ ? payload.items
3158
+ : [];
3159
+ const creatable = Array.isArray(payload)
3160
+ ? false
3161
+ : !!payload?.creatable;
3162
+ if ((session.fileTreeRenderToken || 0) !== renderToken) return;
3163
+
3164
+ const list = this.ensureTreeList(container);
3165
+ this.reconcileTreeList(
3166
+ list,
3167
+ dirPath,
3168
+ files,
3169
+ creatable,
3170
+ session,
3171
+ renderToken
3172
+ );
3173
+ if ((session.fileTreeRenderToken || 0) !== renderToken) return;
1727
3174
 
1728
- const ul = document.createElement('ul');
1729
-
1730
3175
  for (const file of files) {
1731
- const li = document.createElement('li');
1732
- const div = document.createElement('div');
1733
- div.className = 'file-tree-item';
1734
- if (file.isDirectory) div.classList.add('is-dir');
1735
-
1736
- let isExpanded = false;
1737
3176
  if (
1738
3177
  file.isDirectory
1739
- && session.sharedWorkspaceState.expandedPaths.includes(
1740
- file.path
1741
- )
3178
+ && this.getTreeItemExpanded(file.path, session)
1742
3179
  ) {
1743
- isExpanded = true;
1744
- li.classList.add('expanded');
1745
- }
1746
-
1747
- const icon = document.createElement('span');
1748
- icon.className = 'icon';
1749
- icon.innerHTML = this.getIcon(file.name, file.isDirectory, isExpanded);
1750
-
1751
- const name = document.createElement('span');
1752
- name.textContent = file.name;
1753
-
1754
- div.appendChild(icon);
1755
- div.appendChild(name);
1756
-
1757
- div.addEventListener('click', async (e) => {
1758
- e.stopPropagation();
1759
- if (file.isDirectory) {
1760
- if (li.classList.contains('expanded')) {
1761
- li.classList.remove('expanded');
1762
- session.sharedWorkspaceState.expandedPaths =
1763
- session.sharedWorkspaceState.expandedPaths
1764
- .filter((path) => path !== file.path);
1765
- session.saveState({ touchWorkspace: true });
1766
- void session.server.fetch('/api/memory/expand', {
1767
- method: 'POST',
1768
- headers: { 'Content-Type': 'application/json' },
1769
- body: JSON.stringify({
1770
- path: file.path,
1771
- expanded: false
1772
- })
1773
- });
1774
-
1775
- icon.innerHTML = this.getIcon(file.name, true, false);
1776
- const childUl = li.querySelector('ul');
1777
- if (childUl) childUl.remove();
1778
- } else {
1779
- li.classList.add('expanded');
1780
- session.sharedWorkspaceState.expandedPaths =
1781
- uniqueStringList([
1782
- ...session.sharedWorkspaceState.expandedPaths,
1783
- file.path
1784
- ]);
1785
- session.saveState({ touchWorkspace: true });
1786
- void session.server.fetch('/api/memory/expand', {
1787
- method: 'POST',
1788
- headers: { 'Content-Type': 'application/json' },
1789
- body: JSON.stringify({
1790
- path: file.path,
1791
- expanded: true
1792
- })
1793
- });
1794
-
1795
- icon.innerHTML = this.getIcon(file.name, true, true);
1796
- await this.renderTree(file.path, li, session);
1797
- }
1798
- } else {
1799
- await this.openFile(file.path, session);
3180
+ const item = Array.from(list.children).find(
3181
+ (child) => child.dataset.path === file.path
3182
+ );
3183
+ if (item) {
3184
+ void this.renderTree(file.path, item, session, renderToken);
1800
3185
  }
1801
- });
1802
-
1803
- li.appendChild(div);
1804
-
1805
- if (isExpanded) {
1806
- this.renderTree(file.path, li, session);
1807
3186
  }
1808
-
1809
- ul.appendChild(li);
1810
3187
  }
1811
- container.appendChild(ul);
1812
3188
  } catch (err) {
1813
3189
  console.error('Failed to render tree:', err);
1814
3190
  }
1815
3191
  }
1816
3192
 
1817
- async openFile(filePath, sessionOrRestore = this.currentSession) {
3193
+ async openFile(
3194
+ filePath,
3195
+ sessionOrRestore = this.currentSession,
3196
+ options = {}
3197
+ ) {
1818
3198
  const session = typeof sessionOrRestore === 'boolean'
1819
3199
  ? this.currentSession
1820
3200
  : sessionOrRestore;
@@ -1827,20 +3207,10 @@ class EditorManager {
1827
3207
  : session;
1828
3208
  if (!targetSession) return;
1829
3209
  const state = targetSession.editorState;
1830
-
1831
- let touchedWorkspace = false;
1832
- if (!state.openFiles.includes(filePath)) {
1833
- state.openFiles.push(filePath);
1834
- this.renderEditorTabs();
1835
- touchedWorkspace = true;
1836
- }
1837
-
1838
- this.updateEditorPaneVisibility();
3210
+ const wasOpen = state.openFiles.includes(filePath);
3211
+ const isImage = isSupportedImagePath(filePath);
1839
3212
 
1840
3213
  if (!this.getModel(filePath)) {
1841
- const ext = filePath.split('.').pop().toLowerCase();
1842
- const isImage = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext);
1843
-
1844
3214
  let model = null;
1845
3215
  let content = null;
1846
3216
  let readonly = false;
@@ -1850,7 +3220,20 @@ class EditorManager {
1850
3220
  const res = await targetSession.server.fetch(
1851
3221
  `/api/fs/read?path=${encodeURIComponent(filePath)}`
1852
3222
  );
1853
- if (!res.ok) throw new Error('Failed to read file');
3223
+ if (res.status === 415) {
3224
+ await showConfirmModal({
3225
+ title: 'Unsupported File Type',
3226
+ message: 'This file type is not supported yet.',
3227
+ note: 'Only text files and supported images can be opened right now.',
3228
+ confirmLabel: 'OK',
3229
+ hideCancel: true,
3230
+ returnFocus: document.activeElement
3231
+ });
3232
+ return;
3233
+ }
3234
+ if (!res.ok) {
3235
+ throw new Error('Failed to read file');
3236
+ }
1854
3237
  const data = await res.json();
1855
3238
  content = data.content;
1856
3239
  readonly = data.readonly;
@@ -1880,7 +3263,16 @@ class EditorManager {
1880
3263
  });
1881
3264
  }
1882
3265
 
1883
- this.activateFileTab(filePath);
3266
+ let touchedWorkspace = false;
3267
+ if (!wasOpen) {
3268
+ state.openFiles.push(filePath);
3269
+ this.renderEditorTabs();
3270
+ touchedWorkspace = true;
3271
+ }
3272
+
3273
+ this.updateEditorPaneVisibility();
3274
+
3275
+ this.activateFileTab(filePath, false, options);
1884
3276
  if (touchedWorkspace) {
1885
3277
  targetSession.saveState({ touchWorkspace: true });
1886
3278
  }
@@ -2087,9 +3479,10 @@ class EditorManager {
2087
3479
  });
2088
3480
  }
2089
3481
 
2090
- activateFileTab(filePath, isRestore = false) {
3482
+ activateFileTab(filePath, isRestore = false, options = {}) {
2091
3483
  if (!this.currentSession) return;
2092
3484
  if (!filePath) return;
3485
+ const focusEditor = options.focusEditor !== false;
2093
3486
  const state = this.currentSession.editorState;
2094
3487
 
2095
3488
  if (!isRestore && state.activeFilePath && state.activeFilePath !== filePath) {
@@ -2111,7 +3504,7 @@ class EditorManager {
2111
3504
  this.syncTerminalWorkspacePlacement();
2112
3505
 
2113
3506
  if (!file) {
2114
- this.openFile(filePath, true);
3507
+ this.openFile(filePath, true, options);
2115
3508
  return;
2116
3509
  }
2117
3510
 
@@ -2146,7 +3539,9 @@ class EditorManager {
2146
3539
  if (savedViewState) {
2147
3540
  this.editor.restoreViewState(savedViewState);
2148
3541
  }
2149
- this.editor.focus();
3542
+ if (focusEditor) {
3543
+ this.editor.focus();
3544
+ }
2150
3545
  // Force layout to ensure content is visible
2151
3546
  requestAnimationFrame(() => this.editor.layout());
2152
3547
  }
@@ -4640,6 +6035,11 @@ class Session {
4640
6035
  this.layoutState = {
4641
6036
  editorFlex: '2 1 0%'
4642
6037
  };
6038
+ this.selectedTreePath = '';
6039
+ this.treeEditingPath = '';
6040
+ this.treeRenameSubmitting = false;
6041
+ this.pendingTreeFocusPath = '';
6042
+ this.pendingTreeRenameFocusPath = '';
4643
6043
  this.previewRelayoutScheduled = false;
4644
6044
  this.lastTerminalControlClaimAt = 0;
4645
6045
  this.boundTerminalClaimRoot = null;
@@ -4884,11 +6284,12 @@ class Session {
4884
6284
  if (workspaceChanged) {
4885
6285
  if (this.fileTreeElement) {
4886
6286
  if (this.editorState.isVisible) {
4887
- editorManager.refreshSessionTree(this);
6287
+ editorManager.requestSessionTreeRefresh(this);
4888
6288
  } else {
4889
6289
  this.fileTreeElement.innerHTML = '';
4890
6290
  }
4891
6291
  }
6292
+ editorManager.updateTreeAutoRefresh();
4892
6293
  }
4893
6294
  if (workspaceChanged && state.activeSessionKey === this.key) {
4894
6295
  refreshWorkspaceIfSessionActive(this);
@@ -5146,6 +6547,9 @@ class Session {
5146
6547
  this.runningCommand = '';
5147
6548
  this.needsAttention = false;
5148
6549
  this.updateTabUI();
6550
+ if (this.editorState.isVisible) {
6551
+ editorManager.requestSessionTreeRefresh(this);
6552
+ }
5149
6553
  if (state.activeSessionKey === this.key) {
5150
6554
  editorManager.renderEditorTabs();
5151
6555
  }
@@ -5205,6 +6609,10 @@ class Session {
5205
6609
  this.needsAttention = false;
5206
6610
  }
5207
6611
 
6612
+ if (this.editorState.isVisible) {
6613
+ editorManager.requestSessionTreeRefresh(this);
6614
+ }
6615
+
5208
6616
  this.updateTabUI();
5209
6617
  if (state.activeSessionKey === this.key) {
5210
6618
  editorManager.renderEditorTabs();
@@ -10873,10 +12281,69 @@ function createTabElement(session) {
10873
12281
 
10874
12282
  const fileTree = document.createElement('div');
10875
12283
  fileTree.className = 'tab-file-tree';
12284
+ fileTree.tabIndex = 0;
10876
12285
  session.fileTreeElement = fileTree;
12286
+ fileTree.addEventListener('mousedown', (event) => {
12287
+ if (
12288
+ event.target.closest('.file-tree-rename-input')
12289
+ || event.target.closest('.file-tree-rename-btn')
12290
+ ) {
12291
+ return;
12292
+ }
12293
+ if (event.target.closest('.file-tree-item')) {
12294
+ event.preventDefault();
12295
+ fileTree.focus({ preventScroll: true });
12296
+ }
12297
+ });
12298
+ fileTree.addEventListener('keydown', (event) => {
12299
+ if (event.key === 'Escape' && session.treeEditingPath) {
12300
+ event.preventDefault();
12301
+ event.stopPropagation();
12302
+ editorManager.cancelTreeRename(session);
12303
+ editorManager.focusTreePath(session, session.selectedTreePath);
12304
+ return;
12305
+ }
12306
+ if (
12307
+ !session.treeEditingPath
12308
+ && !event.metaKey
12309
+ && !event.ctrlKey
12310
+ && !event.altKey
12311
+ && (
12312
+ event.key === 'Delete'
12313
+ || event.key === 'Backspace'
12314
+ )
12315
+ ) {
12316
+ event.preventDefault();
12317
+ event.stopPropagation();
12318
+ void editorManager.deleteSelectedTreeEntry(session);
12319
+ return;
12320
+ }
12321
+ if (event.key === 'ArrowDown') {
12322
+ event.preventDefault();
12323
+ event.stopPropagation();
12324
+ editorManager.moveTreeSelection(session, 1);
12325
+ editorManager.keepTreeFocus(session);
12326
+ return;
12327
+ }
12328
+ if (event.key === 'ArrowUp') {
12329
+ event.preventDefault();
12330
+ event.stopPropagation();
12331
+ editorManager.moveTreeSelection(session, -1);
12332
+ editorManager.keepTreeFocus(session);
12333
+ return;
12334
+ }
12335
+ if (event.key !== 'Enter' || session.treeEditingPath) {
12336
+ return;
12337
+ }
12338
+ if (!editorManager.beginSelectedTreeRename(session)) {
12339
+ return;
12340
+ }
12341
+ event.preventDefault();
12342
+ event.stopPropagation();
12343
+ });
10877
12344
 
10878
12345
  if (session.editorState && session.editorState.isVisible) {
10879
- editorManager.renderTree(session.cwd, fileTree, session);
12346
+ editorManager.refreshSessionTree(session);
10880
12347
  }
10881
12348
  tab.appendChild(fileTree);
10882
12349
 
@@ -11090,6 +12557,127 @@ function openServerModal(mode, server = null) {
11090
12557
  return true;
11091
12558
  }
11092
12559
 
12560
+ const confirmModalState = {
12561
+ resolve: null,
12562
+ returnFocus: null,
12563
+ preferredFocus: 'confirm',
12564
+ hideCancel: false
12565
+ };
12566
+
12567
+ function isConfirmModalOpen() {
12568
+ return !!confirmModal && confirmModal.style.display !== 'none';
12569
+ }
12570
+
12571
+ function getVisibleConfirmModalButtons() {
12572
+ const buttons = [];
12573
+ if (confirmModalCancel && !confirmModalState.hideCancel) {
12574
+ buttons.push(confirmModalCancel);
12575
+ }
12576
+ if (confirmModalConfirm) {
12577
+ buttons.push(confirmModalConfirm);
12578
+ }
12579
+ return buttons;
12580
+ }
12581
+
12582
+ function getConfirmModalPreferredButton() {
12583
+ if (!confirmModalConfirm) {
12584
+ return null;
12585
+ }
12586
+ if (confirmModalState.hideCancel || !confirmModalCancel) {
12587
+ return confirmModalConfirm;
12588
+ }
12589
+ return confirmModalState.preferredFocus === 'cancel'
12590
+ ? confirmModalCancel
12591
+ : confirmModalConfirm;
12592
+ }
12593
+
12594
+ function settleConfirmModal(result) {
12595
+ if (!confirmModal) return;
12596
+ confirmModal.style.display = 'none';
12597
+ const resolve = confirmModalState.resolve;
12598
+ const returnFocus = confirmModalState.returnFocus;
12599
+ confirmModalState.resolve = null;
12600
+ confirmModalState.returnFocus = null;
12601
+ confirmModalState.preferredFocus = 'confirm';
12602
+ confirmModalState.hideCancel = false;
12603
+ if (returnFocus instanceof HTMLElement) {
12604
+ requestAnimationFrame(() => {
12605
+ try {
12606
+ returnFocus.focus({ preventScroll: true });
12607
+ } catch {
12608
+ // Ignore focus restoration failures.
12609
+ }
12610
+ });
12611
+ }
12612
+ resolve?.(result);
12613
+ }
12614
+
12615
+ function showConfirmModal({
12616
+ title = 'Confirm',
12617
+ message = '',
12618
+ note = '',
12619
+ confirmLabel = 'Confirm',
12620
+ danger = false,
12621
+ hideCancel = false,
12622
+ returnFocus = null
12623
+ } = {}) {
12624
+ if (
12625
+ !confirmModal
12626
+ || !confirmModalTitle
12627
+ || !confirmModalMessage
12628
+ || !confirmModalNote
12629
+ || !confirmModalConfirm
12630
+ || !confirmModalCancel
12631
+ ) {
12632
+ return Promise.resolve(false);
12633
+ }
12634
+ if (confirmModalState.resolve) {
12635
+ settleConfirmModal(false);
12636
+ }
12637
+ confirmModalTitle.textContent = title;
12638
+ confirmModalMessage.textContent = message;
12639
+ confirmModalNote.textContent = note;
12640
+ confirmModalNote.style.display = note ? '' : 'none';
12641
+ confirmModalCancel.style.display = hideCancel ? 'none' : '';
12642
+ confirmModalConfirm.textContent = confirmLabel;
12643
+ confirmModalConfirm.classList.toggle('danger-button', danger);
12644
+ confirmModal.style.display = 'flex';
12645
+ confirmModalState.returnFocus = returnFocus;
12646
+ confirmModalState.hideCancel = hideCancel;
12647
+ confirmModalState.preferredFocus = 'confirm';
12648
+ requestAnimationFrame(() => {
12649
+ getConfirmModalPreferredButton()?.focus({ preventScroll: true });
12650
+ });
12651
+ return new Promise((resolve) => {
12652
+ confirmModalState.resolve = resolve;
12653
+ });
12654
+ }
12655
+
12656
+ function moveConfirmModalFocus(delta) {
12657
+ const buttons = getVisibleConfirmModalButtons();
12658
+ if (!buttons.length || !delta) {
12659
+ return;
12660
+ }
12661
+ if (buttons.length === 1) {
12662
+ buttons[0].focus({ preventScroll: true });
12663
+ return;
12664
+ }
12665
+ const currentIndex = buttons.findIndex(
12666
+ (button) => button === document.activeElement
12667
+ );
12668
+ const baseIndex = currentIndex === -1
12669
+ ? buttons.length - 1
12670
+ : currentIndex;
12671
+ const nextIndex = Math.max(0, Math.min(
12672
+ buttons.length - 1,
12673
+ baseIndex + delta
12674
+ ));
12675
+ confirmModalState.preferredFocus = nextIndex === 0
12676
+ ? 'cancel'
12677
+ : 'confirm';
12678
+ buttons[nextIndex].focus({ preventScroll: true });
12679
+ }
12680
+
11093
12681
  function renderServerControls() {
11094
12682
  if (!serverControlsEl) return;
11095
12683
  serverControlsEl.innerHTML = '';
@@ -11244,10 +12832,14 @@ document.addEventListener('keydown', noteAppInteraction, {
11244
12832
  window.addEventListener('focus', () => {
11245
12833
  noteAppInteraction();
11246
12834
  enterAppNotificationQuietPeriod();
12835
+ editorManager.refreshVisibleSessionTrees();
12836
+ editorManager.updateTreeAutoRefresh();
11247
12837
  });
11248
12838
  window.addEventListener('pageshow', () => {
11249
12839
  noteAppInteraction();
11250
12840
  enterAppNotificationQuietPeriod();
12841
+ editorManager.refreshVisibleSessionTrees();
12842
+ editorManager.updateTreeAutoRefresh();
11251
12843
  });
11252
12844
 
11253
12845
  document.addEventListener('click', () => {
@@ -11258,7 +12850,9 @@ document.addEventListener('visibilitychange', () => {
11258
12850
  noteAppInteraction();
11259
12851
  enterAppNotificationQuietPeriod();
11260
12852
  clearVisibleAttentionState();
12853
+ editorManager.refreshVisibleSessionTrees();
11261
12854
  }
12855
+ editorManager.updateTreeAutoRefresh();
11262
12856
  });
11263
12857
  // #endregion
11264
12858
 
@@ -11654,6 +13248,84 @@ if (
11654
13248
  });
11655
13249
  }
11656
13250
 
13251
+ if (
13252
+ confirmModal
13253
+ && confirmModalCancel
13254
+ && confirmModalConfirm
13255
+ ) {
13256
+ const focusPreferredConfirmButton = () => {
13257
+ requestAnimationFrame(() => {
13258
+ if (!isConfirmModalOpen()) return;
13259
+ const activeElement = document.activeElement;
13260
+ if (activeElement && confirmModal.contains(activeElement)) {
13261
+ return;
13262
+ }
13263
+ getConfirmModalPreferredButton()?.focus({ preventScroll: true });
13264
+ });
13265
+ };
13266
+
13267
+ confirmModalCancel.addEventListener('focus', () => {
13268
+ confirmModalState.preferredFocus = 'cancel';
13269
+ });
13270
+
13271
+ confirmModalConfirm.addEventListener('focus', () => {
13272
+ confirmModalState.preferredFocus = 'confirm';
13273
+ });
13274
+
13275
+ confirmModalCancel.addEventListener('click', () => {
13276
+ settleConfirmModal(false);
13277
+ });
13278
+
13279
+ confirmModalConfirm.addEventListener('click', () => {
13280
+ settleConfirmModal(true);
13281
+ });
13282
+
13283
+ confirmModal.addEventListener('click', (event) => {
13284
+ if (event.target === confirmModal) {
13285
+ settleConfirmModal(false);
13286
+ }
13287
+ });
13288
+
13289
+ confirmModal.addEventListener('focusout', () => {
13290
+ focusPreferredConfirmButton();
13291
+ });
13292
+
13293
+ confirmModal.addEventListener('keydown', (event) => {
13294
+ if (event.key === 'Escape') {
13295
+ event.preventDefault();
13296
+ settleConfirmModal(false);
13297
+ return;
13298
+ }
13299
+ if (event.key === 'ArrowLeft') {
13300
+ event.preventDefault();
13301
+ event.stopPropagation();
13302
+ moveConfirmModalFocus(-1);
13303
+ return;
13304
+ }
13305
+ if (event.key === 'ArrowRight') {
13306
+ event.preventDefault();
13307
+ event.stopPropagation();
13308
+ moveConfirmModalFocus(1);
13309
+ return;
13310
+ }
13311
+ if (event.key === 'Tab') {
13312
+ event.preventDefault();
13313
+ event.stopPropagation();
13314
+ moveConfirmModalFocus(event.shiftKey ? -1 : 1);
13315
+ return;
13316
+ }
13317
+ if (event.key === 'Enter') {
13318
+ event.preventDefault();
13319
+ event.stopPropagation();
13320
+ if (document.activeElement === confirmModalCancel) {
13321
+ settleConfirmModal(false);
13322
+ return;
13323
+ }
13324
+ settleConfirmModal(true);
13325
+ }
13326
+ });
13327
+ }
13328
+
11657
13329
  if (loginForm && passwordInput) {
11658
13330
  loginForm.addEventListener('submit', async (e) => {
11659
13331
  e.preventDefault();