dsh-vscode-mode 0.4.2 → 0.4.4

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/lib/client.js CHANGED
@@ -481,6 +481,16 @@ window.__ModuleLoader__.load({
481
481
  window.dispatchEvent(new CustomEvent("edrv:refresh"));
482
482
  }
483
483
  /**
484
+ * 磁盘文件变化(外部写入/删除):文件树按路径失效对应目录并强制重列。
485
+ * 与 edrv:refresh 分开:这里不触发差异记录重算与 stale 清理,只动目录缓存。
486
+ * @author ddj 2026年09月15号
487
+ * @param path 发生变化的文件路径
488
+ */
489
+ function emitFileChanged(path) {
490
+ if (!path) return;
491
+ window.dispatchEvent(new CustomEvent("edrv:file-changed", { detail: { path } }));
492
+ }
493
+ /**
484
494
  * 打开指定路径并定位到行列(LSP 跳转目标)。
485
495
  * @author ddj 2026年08月27号
486
496
  * @param path 目标路径
@@ -1640,6 +1650,348 @@ window.__ModuleLoader__.load({
1640
1650
  for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
1641
1651
  return btoa(bin);
1642
1652
  }
1653
+ /** 作用域键 → 路径 → 磁盘版本令牌。 */
1654
+ const baselines = /* @__PURE__ */ new Map();
1655
+ /** 作用域键 → 路径 → 已读取过内容的磁盘版本(同版本不重复读盘;含当时的比对结果)。 */
1656
+ const readVersions = /* @__PURE__ */ new Map();
1657
+ /** 作用域键 → 路径 → 待处理同步标记。 */
1658
+ const syncState = /* @__PURE__ */ new Map();
1659
+ /**
1660
+ * 判定一次观测的同步动作(纯函数,判定表见模块头注释)。
1661
+ * @author ddj 2026年09月15号
1662
+ * @param input 观测输入(基线有无/版本是否变化/是否缺失/缓冲是否脏/内容是否相同)
1663
+ * @returns 同步动作
1664
+ */
1665
+ function syncDecision(input) {
1666
+ if (!input.hasBaseline) return "none";
1667
+ if (input.missing) return input.clean ? "deleted" : "none";
1668
+ if (!input.versionChanged) return "none";
1669
+ if (input.clean) return "sync-silent";
1670
+ return input.contentEqual ? "sync-silent" : "conflict";
1671
+ }
1672
+ /**
1673
+ * 从 host 版本条目取可比较的版本令牌(缺失/空串 → null,表示该后端不提供版本)。
1674
+ * @author ddj 2026年09月15号
1675
+ * @param item 版本条目(可为 undefined)
1676
+ * @returns 版本令牌或 null
1677
+ */
1678
+ function versionOf(item) {
1679
+ const version = item?.version;
1680
+ return typeof version === "string" && version ? version : null;
1681
+ }
1682
+ /**
1683
+ * 记入(或更新)某路径的磁盘版本基线;版本为空串时不记(后端不支持版本)。
1684
+ * @author ddj 2026年09月15号
1685
+ * @param scope 工作区作用域键
1686
+ * @param path 文件路径
1687
+ * @param version 磁盘版本令牌
1688
+ */
1689
+ function recordBaseline(scope, path, version) {
1690
+ if (!path || typeof version !== "string" || !version) return;
1691
+ let map = baselines.get(scope);
1692
+ if (!map) {
1693
+ map = /* @__PURE__ */ new Map();
1694
+ baselines.set(scope, map);
1695
+ }
1696
+ const key = String(path).replace(/\\/g, "/");
1697
+ map.delete(key);
1698
+ map.set(key, version);
1699
+ while (map.size > 128) {
1700
+ const oldest = map.keys().next().value;
1701
+ if (oldest === void 0) break;
1702
+ map.delete(oldest);
1703
+ }
1704
+ }
1705
+ /**
1706
+ * 读取某路径的磁盘版本基线。
1707
+ * @author ddj 2026年09月15号
1708
+ * @param scope 工作区作用域键
1709
+ * @param path 文件路径
1710
+ * @returns 版本令牌;无基线 → null
1711
+ */
1712
+ function baselineOf(scope, path) {
1713
+ return baselines.get(scope)?.get(String(path).replace(/\\/g, "/")) ?? null;
1714
+ }
1715
+ /**
1716
+ * 清除某路径的基线(页签关闭、会话销毁时调用)。
1717
+ * @author ddj 2026年09月15号
1718
+ * @param scope 工作区作用域键
1719
+ * @param path 文件路径
1720
+ */
1721
+ function clearBaseline(scope, path) {
1722
+ baselines.get(scope)?.delete(String(path).replace(/\\/g, "/"));
1723
+ }
1724
+ /**
1725
+ * 记入「已读取过内容的磁盘版本」与比结果(同版本不再重复读盘)。
1726
+ * 读盘成功、保存成功、覆盖成功、保留本地推进基线时都要记,避免下一轮白读一次。
1727
+ * @author ddj 2026年09月15号
1728
+ * @param scope 工作区作用域键
1729
+ * @param path 文件路径
1730
+ * @param version 版本令牌(空值忽略)
1731
+ * @param equal 读到的磁盘内容是否与当时的缓冲内容相同
1732
+ */
1733
+ function markReadVersion(scope, path, version, equal = false) {
1734
+ if (!path || typeof version !== "string" || !version) return;
1735
+ let map = readVersions.get(scope);
1736
+ if (!map) {
1737
+ map = /* @__PURE__ */ new Map();
1738
+ readVersions.set(scope, map);
1739
+ }
1740
+ const key = String(path).replace(/\\/g, "/");
1741
+ map.delete(key);
1742
+ map.set(key, {
1743
+ version,
1744
+ equal: equal === true
1745
+ });
1746
+ while (map.size > 128) {
1747
+ const oldest = map.keys().next().value;
1748
+ if (oldest === void 0) break;
1749
+ map.delete(oldest);
1750
+ }
1751
+ }
1752
+ /**
1753
+ * 该磁盘版本是否已读过内容;是则同时给出当时的比对结果(轮询跳过读盘)。
1754
+ * 必须按版本号严格匹配:缓存里可能是更早版本的结果,版本不同一律视为未读。
1755
+ * @author ddj 2026年09月15号
1756
+ * @param scope 工作区作用域键
1757
+ * @param path 文件路径
1758
+ * @param version 当前磁盘版本令牌
1759
+ * @returns 未读过该版本 → null;读过 → { equal: 内容是否与缓冲一致 }
1760
+ */
1761
+ function readVersionOf(scope, path, version) {
1762
+ if (!version) return null;
1763
+ const memo = readVersions.get(scope)?.get(String(path).replace(/\\/g, "/"));
1764
+ if (!memo || memo.version !== version) return null;
1765
+ return { equal: memo.equal };
1766
+ }
1767
+ /**
1768
+ * 清除某路径的已读版本(重载后必须清:下一轮需按新版本重新读盘比对)。
1769
+ * @author ddj 2026年09月15号
1770
+ * @param scope 工作区作用域键
1771
+ * @param path 文件路径
1772
+ */
1773
+ function clearReadVersion(scope, path) {
1774
+ readVersions.get(scope)?.delete(String(path).replace(/\\/g, "/"));
1775
+ }
1776
+ /**
1777
+ * 标记某路径需要用户介入(冲突/文件消失)。
1778
+ * @author ddj 2026年09月15号
1779
+ * @param scope 工作区作用域键
1780
+ * @param path 文件路径
1781
+ * @param kind 标记类型
1782
+ */
1783
+ function markSync(scope, path, kind) {
1784
+ let map = syncState.get(scope);
1785
+ if (!map) {
1786
+ map = /* @__PURE__ */ new Map();
1787
+ syncState.set(scope, map);
1788
+ }
1789
+ const key = String(path).replace(/\\/g, "/");
1790
+ map.delete(key);
1791
+ map.set(key, {
1792
+ path,
1793
+ kind,
1794
+ at: Date.now()
1795
+ });
1796
+ while (map.size > 64) {
1797
+ const oldest = map.keys().next().value;
1798
+ if (oldest === void 0) break;
1799
+ map.delete(oldest);
1800
+ }
1801
+ }
1802
+ /**
1803
+ * 读取某路径的待处理同步标记。
1804
+ * @author ddj 2026年09月15号
1805
+ * @param scope 工作区作用域键
1806
+ * @param path 文件路径
1807
+ * @returns 标记;无 → null
1808
+ */
1809
+ function readSync(scope, path) {
1810
+ return syncState.get(scope)?.get(String(path).replace(/\\/g, "/")) ?? null;
1811
+ }
1812
+ /**
1813
+ * 清除某路径的同步标记(重新加载/保留本地/保存成功后调用)。
1814
+ * @author ddj 2026年09月15号
1815
+ * @param scope 工作区作用域键
1816
+ * @param path 文件路径
1817
+ */
1818
+ function clearSync(scope, path) {
1819
+ syncState.get(scope)?.delete(String(path).replace(/\\/g, "/"));
1820
+ }
1821
+ //#endregion
1822
+ //#region src/client/ui/useFileWatch.ts
1823
+ /**
1824
+ * dsh-vscode-mode client — 已打开文件的外部改动轮询。
1825
+ *
1826
+ * 背景:RPC 只有客户端拉取通道(无服务端推送),编辑区因此无法感知外部写盘。
1827
+ * 本 hook 以轻量接口补上感知:每 POLL_MS 一次把「全部已打开页签路径」批量交给
1828
+ * host edrv.versions(单请求多条 stat),逐条与本地基线比对后按 watchDecision 的
1829
+ * 判定表把动作回调给 EditorView 执行(IO 与 UI 都在那里)。
1830
+ *
1831
+ * 为何「版本变了还要读内容」:host 版本令牌含 ctime,同字节重写(格式化工具/同步盘/
1832
+ * 编辑器保存策略)也会让它变化,只看版本会把无实质变化的文件反复重载(打断光标与
1833
+ * 滚动)。故版本变化时读一次磁盘内容与缓冲比对,相同则只推进基线;读取结果按版本
1834
+ * 记入 readVersions,同一版本只读一次(陈旧缓冲长期不处理也不会反复拉大文件)。
1835
+ *
1836
+ * 取舍:不使用 fs.watch(句柄/网络盘/长路径兼容性差),纯 stat+按需读轮询跨平台稳定
1837
+ * 且可单测;1.5s 周期对「外部改文件 → 编辑区可见」足够及时,标签页隐藏时整轮跳过。
1838
+ *
1839
+ * 上报规则(避免每轮都弹提示):
1840
+ * - 首次观测某路径(无标记):报 conflict/deleted,并落标记;
1841
+ * - 已有标记且仍异常:不再重复回调(提示已在屏上),版本恢复一致时自动清标记;
1842
+ * - 站点不可达(老 host 无此方法/离线):整轮静默放弃,不清标记、不放大重试。
1843
+ *
1844
+ * 作者 ddj 2026-09-15
1845
+ */
1846
+ /** 轮询周期:外部改动可见延迟上限(标签页隐藏时跳过整轮)。 */
1847
+ const POLL_MS = 1500;
1848
+ /**
1849
+ * 装配外部改动轮询(挂载即开始,卸载/换会话即停)。
1850
+ * @author ddj 2026年09月15号
1851
+ * @param options 会话/作用域/ref 与回调
1852
+ */
1853
+ function useFileWatch(options) {
1854
+ const { sessionId, scope, tabsRef, dirtyRef, onReadDisk, onDiskChange } = options;
1855
+ const cbRef = react.useRef(onDiskChange);
1856
+ cbRef.current = onDiskChange;
1857
+ const readRef = react.useRef(onReadDisk);
1858
+ readRef.current = onReadDisk;
1859
+ /** 在途守卫:上一轮未返回(host 卡住)时跳过本轮,避免请求堆积。 */
1860
+ const busyRef = react.useRef(false);
1861
+ react.useEffect(() => {
1862
+ if (!sessionId) return;
1863
+ const context = {
1864
+ sessionId,
1865
+ scope,
1866
+ dirtyRef,
1867
+ onReadDisk: (path) => readRef.current ? readRef.current(path) : Promise.resolve(null),
1868
+ onChange: (change) => cbRef.current(change)
1869
+ };
1870
+ /**
1871
+ * 一轮观测:批量取版本 → 逐条判定 → 回调。
1872
+ * @author ddj 2026年09月15号
1873
+ */
1874
+ const poll = async () => {
1875
+ if (busyRef.current) return;
1876
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
1877
+ const paths = (tabsRef.current ?? []).filter((p) => typeof p === "string" && p);
1878
+ if (!paths.length) return;
1879
+ busyRef.current = true;
1880
+ try {
1881
+ const res = await rpc("edrv.versions", {
1882
+ sessionId,
1883
+ paths
1884
+ });
1885
+ if (!res || !res.ok || !Array.isArray(res.items)) return;
1886
+ for (const item of res.items) {
1887
+ if (!item || !item.path) continue;
1888
+ await evaluate(item, context);
1889
+ }
1890
+ } catch (error) {} finally {
1891
+ busyRef.current = false;
1892
+ }
1893
+ };
1894
+ const timer = window.setInterval(() => {
1895
+ poll();
1896
+ }, POLL_MS);
1897
+ poll();
1898
+ return () => window.clearInterval(timer);
1899
+ }, [sessionId, scope]);
1900
+ }
1901
+ /**
1902
+ * 单条判定:按 watchDecision 的判定表决定动作,必要时清/落台账标记。
1903
+ * 版本变化时先读磁盘内容(同版本只读一次)再定分支:内容相同只推进基线,
1904
+ * 内容不同且缓冲脏才升级为冲突提示。
1905
+ *
1906
+ * 导出原因:这是「观测 → 动作派发」的关键落点(三条分支各自的副作用不轻),
1907
+ * 用假 fetch + 假 context 可直接驱动它做端到端断言,无需渲染 React 树。
1908
+ * 仅供测试与同模块内 poll 调用,不属于对外 API。
1909
+ * @author ddj 2026年09月15号
1910
+ * @param item host 版本条目
1911
+ * @param context 轮询上下文(作用域/脏标记/读盘/回调)
1912
+ */
1913
+ async function evaluate(item, context) {
1914
+ const path = item.path;
1915
+ const scope = context.scope;
1916
+ const baseline = baselineOf(scope, path);
1917
+ const version = versionOf(item);
1918
+ const missing = item.type === "missing";
1919
+ const dirty = context.dirtyRef.current?.[path] === true;
1920
+ if (!baseline) {
1921
+ recordBaseline(scope, path, version);
1922
+ return;
1923
+ }
1924
+ const versionChanged = version !== baseline;
1925
+ if (!versionChanged && !missing) {
1926
+ clearSync(scope, path);
1927
+ return;
1928
+ }
1929
+ const mode = await inspect(path, version, context);
1930
+ if (mode.known && mode.equal) {
1931
+ recordBaseline(scope, path, version);
1932
+ markReadVersion(scope, path, version, true);
1933
+ clearSync(scope, path);
1934
+ return;
1935
+ }
1936
+ const action = syncDecision({
1937
+ hasBaseline: true,
1938
+ versionChanged,
1939
+ missing,
1940
+ clean: !dirty,
1941
+ contentEqual: mode.equal
1942
+ });
1943
+ if (action === "sync-silent") {
1944
+ clearSync(scope, path);
1945
+ if (mode.known) {
1946
+ recordBaseline(scope, path, version);
1947
+ markReadVersion(scope, path, version, false);
1948
+ } else clearReadVersion(scope, path);
1949
+ context.onChange({
1950
+ path,
1951
+ kind: "modified"
1952
+ });
1953
+ return;
1954
+ }
1955
+ if (action === "none") return;
1956
+ const kind = action === "deleted" ? "deleted" : "conflict";
1957
+ const flagged = readSync(scope, path);
1958
+ if (flagged && flagged.kind === kind) return;
1959
+ markSync(scope, path, kind);
1960
+ context.onChange({
1961
+ path,
1962
+ kind
1963
+ });
1964
+ }
1965
+ /**
1966
+ * 读盘并与调用方缓冲比对(脏缓冲也需要:内容相同就不该报冲突)。
1967
+ * 同版本只读一次:已读过该版本的路径直接回放当时的比对结果(不重复拉取大文件)。
1968
+ * @author ddj 2026年09月15号
1969
+ * @param path 文件路径
1970
+ * @param version 当前磁盘版本(可为 null)
1971
+ * @param context 轮询上下文
1972
+ * @returns { known: 是否拿到磁盘内容, equal: 内容是否与缓冲一致 }
1973
+ */
1974
+ async function inspect(path, version, context) {
1975
+ if (!version) return {
1976
+ known: false,
1977
+ equal: false
1978
+ };
1979
+ const cached = readVersionOf(context.scope, path, version);
1980
+ if (cached) return {
1981
+ known: true,
1982
+ equal: cached.equal
1983
+ };
1984
+ const disk = await context.onReadDisk?.(path).catch(() => null);
1985
+ if (!disk) return {
1986
+ known: false,
1987
+ equal: false
1988
+ };
1989
+ markReadVersion(context.scope, path, version, disk.equal);
1990
+ return {
1991
+ known: true,
1992
+ equal: disk.equal
1993
+ };
1994
+ }
1643
1995
  //#endregion
1644
1996
  //#region src/client/pdf/pdfLoader.ts
1645
1997
  /**
@@ -4278,6 +4630,17 @@ window.__ModuleLoader__.load({
4278
4630
  return activePath ? "editor" : "editor-empty";
4279
4631
  }
4280
4632
  /**
4633
+ * 文件是否仍可执行 Keep/Undo:可定位差异与无法定位的冲突差异都算待处理。
4634
+ * 冲突差异的 newText 已不在文件中(被后续修改覆盖),由 host 按"已不存在"记录决策。
4635
+ * @author ddj 2026年09月15号
4636
+ * @param pendingCount 可定位的待处理差异数
4637
+ * @param staleCount 无法定位(冲突)的待处理差异数
4638
+ * @returns 两者任一大于 0 时为 true
4639
+ */
4640
+ function canDecideFile(pendingCount, staleCount) {
4641
+ return Math.max(0, pendingCount) + Math.max(0, staleCount) > 0;
4642
+ }
4643
+ /**
4281
4644
  * 获取切换文件期间稳定展示的文件内差异数量。
4282
4645
  * @author ddj 2026年08月26号
4283
4646
  * @param ready 当前文件内容是否已加载完成
@@ -7177,6 +7540,8 @@ window.__ModuleLoader__.load({
7177
7540
  activeRef.current = active;
7178
7541
  const tabsRef = react.default.useRef([]);
7179
7542
  tabsRef.current = tabs;
7543
+ const tabPathsRef = react.default.useRef([]);
7544
+ tabPathsRef.current = tabs.map((t) => t.path);
7180
7545
  const dirtyRef = react.default.useRef({});
7181
7546
  dirtyRef.current = dirtyMap;
7182
7547
  const tabsHostRef = react.default.useRef(null);
@@ -7195,6 +7560,9 @@ window.__ModuleLoader__.load({
7195
7560
  const doSaveRef = react.default.useRef(null);
7196
7561
  const onEditRef = react.default.useRef(null);
7197
7562
  const saveViewStateRef = react.default.useRef(null);
7563
+ const revRef = react.default.useRef({});
7564
+ const diskChangeRef = react.default.useRef(null);
7565
+ const [diskFlag, setDiskFlag] = react.default.useState(null);
7198
7566
  const diffRendererRef = react.default.useRef(null);
7199
7567
  const layoutRef = react.default.useRef(layout);
7200
7568
  layoutRef.current = layout;
@@ -7222,6 +7590,7 @@ window.__ModuleLoader__.load({
7222
7590
  ]);
7223
7591
  const pendingRegions = react.default.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions]);
7224
7592
  const staleRegions = react.default.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions]);
7593
+ const decideRegions = react.default.useMemo(() => [...pendingRegions, ...staleRegions], [pendingRegions, staleRegions]);
7225
7594
  hoverRegionsRef.current = pendingRegions;
7226
7595
  lineRegionMapRef.current = react.default.useMemo(() => {
7227
7596
  const map = /* @__PURE__ */ new Map();
@@ -7377,6 +7746,10 @@ window.__ModuleLoader__.load({
7377
7746
  pdfCtlRef.current.delete(path);
7378
7747
  }
7379
7748
  pdfB64CacheRef.current.delete(path);
7749
+ clearBaseline(scope, path);
7750
+ clearReadVersion(scope, path);
7751
+ clearSync(scope, path);
7752
+ delete revRef.current[path];
7380
7753
  };
7381
7754
  /**
7382
7755
  * 提交关闭结果:一次性更新页签与活动页签(补位规则见 tabActions.applyClose)。
@@ -7468,19 +7841,33 @@ window.__ModuleLoader__.load({
7468
7841
  /**
7469
7842
  * 加载文件内容(对齐 VSCode model 复用:会话内已打开的 model 直接秒显,
7470
7843
  * 后台静默 RPC 校验防陈旧;首次打开走原读取流程)。
7471
- * @author ddj 2026年08月28号
7844
+ * 每条成功分支都记下磁盘版本基线:外部改动轮询据此判断缓冲是否已陈旧,
7845
+ * 保存时作为版本守卫令牌随 edrv.save 回传(读后被外部改过则拒绝写入)。
7846
+ * @author ddj 2026年08月28号 / 2026年09月15号
7472
7847
  * @param path 文件路径
7473
7848
  * @param sid 会话 id
7474
7849
  * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
7850
+ * @param version 已知磁盘版本(缺省走 RPC 返回值)
7475
7851
  */
7476
- const loadContent = (path, sid, force) => {
7852
+ const loadContent = (path, sid, force, version) => {
7477
7853
  const seq = ++loadSeqRef.current;
7854
+ /**
7855
+ * 记下磁盘版本基线;markRead=true 表示本次真的从磁盘读了内容
7856
+ * (已读版本台账随之失效,下一轮轮询按新版本重新比对)。
7857
+ */
7858
+ const mark = (ver, markRead) => {
7859
+ if (typeof ver !== "string" || !ver) return;
7860
+ revRef.current[path] = ver;
7861
+ recordBaseline(scope, path, ver);
7862
+ clearSync(scope, path);
7863
+ if (markRead === true) clearReadVersion(scope, path);
7864
+ };
7478
7865
  if (isImagePath(path) && !svgTextRef.current.has(path)) {
7479
- loadImage(path, sid, seq);
7866
+ loadImage(path, sid, seq, version);
7480
7867
  return;
7481
7868
  }
7482
7869
  if (isPdfPath(path)) {
7483
- loadPdf(path, sid, seq, force === true);
7870
+ loadPdf(path, sid, seq, force === true, version);
7484
7871
  return;
7485
7872
  }
7486
7873
  const cachedModel = force ? null : modelsRef.current.get(path);
@@ -7497,6 +7884,7 @@ window.__ModuleLoader__.load({
7497
7884
  path
7498
7885
  }).then((res) => {
7499
7886
  if (seq !== loadSeqRef.current || path !== active) return;
7887
+ if (res && res.ok) mark(res.version);
7500
7888
  if (res && res.ok && res.content !== cachedModel.getValue()) {
7501
7889
  saveViewState(path);
7502
7890
  setContent(res.content);
@@ -7516,6 +7904,7 @@ window.__ModuleLoader__.load({
7516
7904
  }).then((res) => {
7517
7905
  if (seq !== loadSeqRef.current || path !== active) return;
7518
7906
  if (res && res.ok) {
7907
+ mark(res.version, true);
7519
7908
  setContent(res.content);
7520
7909
  setContentPath(path);
7521
7910
  setLoadStage((prev) => ({
@@ -7540,12 +7929,13 @@ window.__ModuleLoader__.load({
7540
7929
  };
7541
7930
  /**
7542
7931
  * 加载图片文件为 data URL(编辑区只读预览);失败走 loadError 面板(重试经 loadContent 分派回此)。
7543
- * @author ddj 2026年09月08号
7932
+ * @author ddj 2026年09月08号 / 2026年09月15号
7544
7933
  * @param path 图片文件路径
7545
7934
  * @param sid 会话 id
7546
7935
  * @param seq 加载序号(过期响应丢弃)
7936
+ * @param version 已知磁盘版本(图片同走外部改动轮询,读到新版本即重取)
7547
7937
  */
7548
- const loadImage = (path, sid, seq) => {
7938
+ const loadImage = (path, sid, seq, version) => {
7549
7939
  setImageSrc(null);
7550
7940
  setImgSize(null);
7551
7941
  setImgBroken(false);
@@ -7566,6 +7956,9 @@ window.__ModuleLoader__.load({
7566
7956
  setStatus("读取失败");
7567
7957
  return;
7568
7958
  }
7959
+ const imgVersion = res.version ?? version;
7960
+ recordBaseline(scope, path, imgVersion);
7961
+ clearReadVersion(scope, path);
7569
7962
  setImageSrc(dataUrlOf(res.content, res.mime));
7570
7963
  setLoadStage({
7571
7964
  progress: 100,
@@ -7595,8 +7988,9 @@ window.__ModuleLoader__.load({
7595
7988
  * @param sid 会话 id
7596
7989
  * @param seq 加载序号(过期响应丢弃)
7597
7990
  * @param force true=绕过缓存强制 RPC 重读(刷新按钮)
7991
+ * @param version 已知磁盘版本(外部改动轮询触发时带入)
7598
7992
  */
7599
- const loadPdf = (path, sid, seq, force) => {
7993
+ const loadPdf = (path, sid, seq, force, version) => {
7600
7994
  setPdfBytes(null);
7601
7995
  setLoadStage({
7602
7996
  progress: monaco ? 72 : 12,
@@ -7622,6 +8016,9 @@ window.__ModuleLoader__.load({
7622
8016
  }).then((res) => {
7623
8017
  if (seq !== loadSeqRef.current || path !== active) return;
7624
8018
  if (res && res.ok && res.encoding === "base64") {
8019
+ const pdfVersion = res.version ?? version;
8020
+ recordBaseline(scope, path, pdfVersion);
8021
+ clearReadVersion(scope, path);
7625
8022
  cache.set(path, res.content);
7626
8023
  while (cache.size > 6) cache.delete(cache.keys().next().value);
7627
8024
  setPdfBytes(base64ToBytes(res.content));
@@ -7656,6 +8053,66 @@ window.__ModuleLoader__.load({
7656
8053
  window.removeEventListener("edrv:refresh", onRefresh);
7657
8054
  };
7658
8055
  }, [sessionId]);
8056
+ /**
8057
+ * 展示外部同步提示(按路径+类型去重:同一文件重复回调不重置已关闭的提示)。
8058
+ * @author ddj 2026年09月15号
8059
+ * @param flag 同步标记
8060
+ */
8061
+ const markDiskFlag = (flag) => {
8062
+ setDiskFlag((prev) => prev && prev.path === flag.path && prev.kind === flag.kind ? prev : flag);
8063
+ };
8064
+ /**
8065
+ * 外部改动落地:干净缓冲直接刷入(含差异标记重算),脏缓冲只提示不覆盖。
8066
+ * 经 ref 暴露给轮询 hook(hook 只负责观测,IO/UI 全在这里)。
8067
+ * @author ddj 2026年09月15号
8068
+ * @param change 轮询判定出的外部变更
8069
+ */
8070
+ const onDiskChange = (change) => {
8071
+ if (!change || !change.path) return;
8072
+ emitFileChanged(change.path);
8073
+ if (change.kind === "modified") {
8074
+ loadContent(change.path, sessionId, true);
8075
+ clearSync(scope, change.path);
8076
+ setDiskFlag((prev) => prev && prev.path === change.path ? null : prev);
8077
+ setStatus("已同步外部修改 " + (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8));
8078
+ emitRefresh();
8079
+ return;
8080
+ }
8081
+ const kind = change.kind === "deleted" ? "deleted" : "conflict";
8082
+ markDiskFlag(readSync(scope, change.path) ?? {
8083
+ path: change.path,
8084
+ kind,
8085
+ at: Date.now()
8086
+ });
8087
+ setStatus(kind === "deleted" ? "文件已被外部删除" : "外部已修改(未保存的编辑保留)");
8088
+ };
8089
+ diskChangeRef.current = onDiskChange;
8090
+ /**
8091
+ * 版本变化时读磁盘并与缓冲比对:内容相同 → 只推进基线(不做无意义重载);
8092
+ * 内容不同 → 由判定表决定自动刷入还是冲突提示。
8093
+ * @author ddj 2026年09月15号
8094
+ * @param path 文件路径
8095
+ * @returns 比对结果;模型不可用/读失败 → null(按「需要重载」处理)
8096
+ */
8097
+ const readDiskCompare = (path) => {
8098
+ const model = modelsRef.current?.get(path);
8099
+ if (!model || typeof model.getValue !== "function") return Promise.resolve(null);
8100
+ return rpc("edrv.read", {
8101
+ sessionId,
8102
+ path
8103
+ }).then((res) => {
8104
+ if (!res || !res.ok) return null;
8105
+ return { equal: res.content === model.getValue() };
8106
+ }).catch(() => null);
8107
+ };
8108
+ useFileWatch({
8109
+ sessionId,
8110
+ scope,
8111
+ tabsRef: tabPathsRef,
8112
+ dirtyRef,
8113
+ onReadDisk: readDiskCompare,
8114
+ onDiskChange: (change) => diskChangeRef.current?.(change)
8115
+ });
7659
8116
  react.default.useEffect(() => {
7660
8117
  const publishLsp = (servers) => setLspServers(Array.isArray(servers) ? servers : []);
7661
8118
  const unsubscribe = onLspProgress(publishLsp);
@@ -8176,13 +8633,24 @@ window.__ModuleLoader__.load({
8176
8633
  * 提交待执行的防抖保存(**真正执行保存**,不是取消)。
8177
8634
  * 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
8178
8635
  * 旧实现把它当「立即保存」调用 → 防抖窗口内切页签/关闭文件会静默丢改动。
8179
- * @author ddj 2026年09月11号
8636
+ * 外部改动待处理(冲突/文件被删)时跳过:切页签/卸载不得用陈旧缓冲覆盖磁盘,
8637
+ * 缓冲内容仍在 model 里,用户处理完冲突后再保存。
8638
+ * @author ddj 2026年09月11号 / 2026年09月15号
8639
+ * @param path 目标路径(缺省 = 当前活动文件)
8640
+ * @returns 是否执行了保存
8180
8641
  */
8181
- const flushSave = () => {
8642
+ const flushSave = (path) => {
8643
+ const target = path ?? active;
8644
+ if (target && readSync(scope, target)) return false;
8182
8645
  saveTimerRef.current?.flush();
8646
+ return true;
8183
8647
  };
8184
8648
  const doSave = (silent) => {
8185
8649
  if (!active) return;
8650
+ if (silent && readSync(scope, active)) {
8651
+ setStatus("外部已修改(未保存的编辑保留)");
8652
+ return;
8653
+ }
8186
8654
  if (isPdfPath(active)) {
8187
8655
  const ctl = pdfCtlRef.current.get(active);
8188
8656
  if (!ctl) {
@@ -8203,30 +8671,49 @@ window.__ModuleLoader__.load({
8203
8671
  return;
8204
8672
  }
8205
8673
  const text = ed.getValue();
8674
+ const path = active;
8206
8675
  if (!silent) setStatus("保存中…");
8207
- savingRef.current.add(active);
8676
+ savingRef.current.add(path);
8677
+ const rev = revRef.current[path];
8208
8678
  rpc("edrv.save", {
8209
8679
  sessionId,
8210
- path: active,
8211
- content: text
8680
+ path,
8681
+ content: text,
8682
+ rev: typeof rev === "string" && rev ? rev : void 0
8212
8683
  }).then((res) => {
8213
8684
  if (res && res.ok) {
8214
- setStatus("已保存 " + (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8));
8685
+ if (res.rev) revRef.current[path] = res.rev;
8686
+ recordBaseline(scope, path, res.rev);
8687
+ markReadVersion(scope, path, res.rev, true);
8688
+ clearSync(scope, path);
8215
8689
  setContent(text);
8216
- setContentPath(active);
8217
- setDirtyMap((d) => Object.assign({}, d, { [active]: false }));
8690
+ setContentPath(path);
8691
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }));
8692
+ if (path === active) {
8693
+ setStatus("已保存 " + (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8));
8694
+ setDiskFlag((prev) => prev && prev.path === path ? null : prev);
8695
+ }
8218
8696
  refreshRecords();
8219
8697
  emitRefresh();
8220
- if (/\.code-snippets$/i.test(active)) window.dispatchEvent(new CustomEvent("edrv:snippets-changed"));
8221
- } else {
8222
- setStatus("保存失败");
8223
- setError(res?.error ? String(res.error) : "保存失败");
8698
+ if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent("edrv:snippets-changed"));
8699
+ return;
8224
8700
  }
8701
+ if (res && res.conflict) {
8702
+ markDiskFlag({
8703
+ path,
8704
+ kind: "conflict",
8705
+ at: Date.now()
8706
+ });
8707
+ setStatus("保存被拒:文件已被外部修改");
8708
+ return;
8709
+ }
8710
+ setStatus("保存失败");
8711
+ setError(res?.error ? String(res.error) : "保存失败");
8225
8712
  }).catch((e) => {
8226
8713
  setStatus("保存失败");
8227
8714
  setError("保存异常:" + String(e));
8228
8715
  }).finally(() => {
8229
- savingRef.current.delete(active);
8716
+ savingRef.current.delete(path);
8230
8717
  });
8231
8718
  };
8232
8719
  doSaveRef.current = doSave;
@@ -8237,12 +8724,17 @@ window.__ModuleLoader__.load({
8237
8724
  * 这里再处理**仍标脏**的页签 —— 包括活动页签(其保存可能在途或失败),
8238
8725
  * 用 model 里的当前文本补一次,保证关闭前内容一定写到磁盘。
8239
8726
  * 保存失败只保留脏标记(不丢用户编辑),并在状态栏给出提示。
8240
- * @author ddj 2026年09月11号
8727
+ * 外部改动待处理的页签跳过(版本守卫下必被拒绝;缓冲仍在 model 里,不丢内容)。
8728
+ * @author ddj 2026年09月11号 / 2026年09月15号
8241
8729
  * @param paths 即将关闭的页签路径
8242
8730
  */
8243
8731
  const persistDirty = (paths) => {
8244
8732
  for (const path of paths) {
8245
8733
  if (!dirtyRef.current[path]) continue;
8734
+ if (readSync(scope, path)) {
8735
+ setStatus("未落盘(外部已修改):" + baseNameOf(path));
8736
+ continue;
8737
+ }
8246
8738
  if (savingRef.current.has(path)) continue;
8247
8739
  const pdfCtl = pdfCtlRef.current.get(path);
8248
8740
  if (pdfCtl) {
@@ -8256,12 +8748,19 @@ window.__ModuleLoader__.load({
8256
8748
  }
8257
8749
  const content = model.getValue();
8258
8750
  savingRef.current.add(path);
8751
+ const rev = revRef.current[path];
8259
8752
  rpc("edrv.save", {
8260
8753
  sessionId,
8261
8754
  path,
8262
- content
8755
+ content,
8756
+ rev: typeof rev === "string" && rev ? rev : void 0
8263
8757
  }).then((res) => {
8264
- if (res && res.ok) setDirtyMap((d) => Object.assign({}, d, { [path]: false }));
8758
+ if (res && res.ok) {
8759
+ if (res.rev) revRef.current[path] = res.rev;
8760
+ recordBaseline(scope, path, res.rev);
8761
+ markReadVersion(scope, path, res.rev, true);
8762
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }));
8763
+ }
8265
8764
  }).catch((e) => dbg(sessionId, "关闭前落盘失败:" + path + " · " + String(e))).finally(() => {
8266
8765
  savingRef.current.delete(path);
8267
8766
  });
@@ -8271,6 +8770,10 @@ window.__ModuleLoader__.load({
8271
8770
  if (!editorRef.current || !active) return;
8272
8771
  setDirtyMap((d) => Object.assign({}, d, { [active]: true }));
8273
8772
  setStatus("编辑中…");
8773
+ if (readSync(scope, active)) {
8774
+ setStatus("外部已修改(未保存的编辑保留)");
8775
+ return;
8776
+ }
8274
8777
  saveTimerRef.current?.arm(schedule, 700, () => doSave(true));
8275
8778
  };
8276
8779
  onEditRef.current = onEdit;
@@ -8622,12 +9125,80 @@ window.__ModuleLoader__.load({
8622
9125
  if (sum.pendingFiles.some((file) => sameFile(file.path, active))) gotoFile(1);
8623
9126
  else openFile(sum.pendingFiles[0].path, true);
8624
9127
  };
9128
+ /**
9129
+ * 重新从磁盘加载当前文件(工具栏 ⟳ / 冲突提示「重新加载」/ 差异决策后刷新)。
9130
+ * 一律强制重读(跳过 model 复用):这才是「用户要看到磁盘真实内容」的语义。
9131
+ * @author ddj 2026年09月15号
9132
+ * @param skipStale 差异记录刷新是否跳过 stale 清理
9133
+ */
8625
9134
  const reloadFile = (skipStale) => {
8626
9135
  if (!active) return;
8627
- loadContent(active, sessionId, true);
9136
+ const path = active;
9137
+ loadContent(path, sessionId, true);
9138
+ clearSync(scope, path);
9139
+ setDiskFlag((prev) => prev && prev.path === path ? null : prev);
8628
9140
  refreshRecords(skipStale === true);
8629
9141
  };
8630
9142
  /**
9143
+ * 冲突处理:用编辑器内容覆盖磁盘(在缓冲内容为权威时用户显式选择)。
9144
+ * 覆盖不带版本令牌(host 无条件写入),成功后以返回的新版本重记基线。
9145
+ * @author ddj 2026年09月15号
9146
+ */
9147
+ const overwriteDisk = () => {
9148
+ const path = diskFlag?.path;
9149
+ const model = path ? modelsRef.current.get(path) : null;
9150
+ if (!path || !model) {
9151
+ setStatus("无法覆盖:文件未打开");
9152
+ return;
9153
+ }
9154
+ rpc("edrv.save", {
9155
+ sessionId,
9156
+ path,
9157
+ content: model.getValue()
9158
+ }).then((res) => {
9159
+ if (res && res.ok) {
9160
+ if (res.rev) revRef.current[path] = res.rev;
9161
+ recordBaseline(scope, path, res.rev);
9162
+ markReadVersion(scope, path, res.rev, true);
9163
+ clearSync(scope, path);
9164
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }));
9165
+ setDiskFlag((prev) => prev && prev.path === path ? null : prev);
9166
+ setStatus("已覆盖磁盘");
9167
+ emitRefresh();
9168
+ return;
9169
+ }
9170
+ setStatus("覆盖失败");
9171
+ setError(res?.error ? String(res.error) : "覆盖失败");
9172
+ }).catch((e) => setError("覆盖异常:" + String(e)));
9173
+ };
9174
+ /**
9175
+ * 冲突处理:保留本地编辑(关掉提示),磁盘内容不取用。
9176
+ * 基线推进到磁盘当前版本,避免同一外部改动被反复提示;缓冲仍标脏,
9177
+ * 之后的手工保存会被版本守卫拒绝并再次给出选择。
9178
+ * @author ddj 2026年09月15号
9179
+ */
9180
+ const keepLocal = () => {
9181
+ const path = diskFlag?.path;
9182
+ if (!path) return;
9183
+ rpc("edrv.versions", {
9184
+ sessionId,
9185
+ paths: [path]
9186
+ }).then((res) => {
9187
+ const item = res && res.ok && Array.isArray(res.items) ? res.items[0] : null;
9188
+ if (item && item.version) {
9189
+ recordBaseline(scope, path, item.version);
9190
+ markReadVersion(scope, path, item.version, false);
9191
+ revRef.current[path] = item.version;
9192
+ }
9193
+ clearSync(scope, path);
9194
+ setDiskFlag((prev) => prev && prev.path === path ? null : prev);
9195
+ setStatus("已保留本地编辑(磁盘内容未取用)");
9196
+ }).catch(() => {
9197
+ clearSync(scope, path);
9198
+ setDiskFlag(null);
9199
+ });
9200
+ };
9201
+ /**
8631
9202
  * SVG 在图片预览与文本编辑间切换(按路径记忆;重载分派随集合状态自动路由)。
8632
9203
  * @author ddj 2026年09月08号
8633
9204
  * @param path SVG 文件路径
@@ -8725,12 +9296,13 @@ window.__ModuleLoader__.load({
8725
9296
  * 单次 setRecords 合并全部结果,避免逐条往返读写整个 sidecar。
8726
9297
  * @author ddj 2026年08月25号
8727
9298
  * @param items 决策项数组(callId/scope/hunkIndex/decision)
8728
- * @returns Promise<{ok:number; fail:number}> 成功/失败计数
9299
+ * @returns Promise<{ok:number; fail:number; stale:number}> 成功/失败/已不存在计数
8729
9300
  */
8730
9301
  const actMany = (items) => {
8731
9302
  if (!items.length) return Promise.resolve({
8732
9303
  ok: 0,
8733
- fail: 0
9304
+ fail: 0,
9305
+ stale: 0
8734
9306
  });
8735
9307
  return rpc("edrv.decideBatch", {
8736
9308
  sessionId,
@@ -8740,30 +9312,46 @@ window.__ModuleLoader__.load({
8740
9312
  setError(res?.error ? String(res.error) : "批量处理失败");
8741
9313
  return {
8742
9314
  ok: 0,
8743
- fail: items.length
9315
+ fail: items.length,
9316
+ stale: 0
8744
9317
  };
8745
9318
  }
8746
9319
  let ok = 0;
8747
9320
  let fail = 0;
9321
+ let stale = 0;
8748
9322
  const next = {};
8749
9323
  for (const item of res.results) if (item && item.ok) {
8750
9324
  ok++;
9325
+ if (item.stale === true) stale++;
8751
9326
  if (item.record) next[item.callId] = item.record;
8752
9327
  } else fail++;
8753
9328
  if (Object.keys(next).length) setRecords((prev) => Object.assign({}, prev, next));
8754
9329
  return {
8755
9330
  ok,
8756
- fail
9331
+ fail,
9332
+ stale
8757
9333
  };
8758
9334
  }).catch((e) => {
8759
9335
  setError("批量处理异常:" + String(e));
8760
9336
  return {
8761
9337
  ok: 0,
8762
- fail: items.length
9338
+ fail: items.length,
9339
+ stale: 0
8763
9340
  };
8764
9341
  });
8765
9342
  };
8766
9343
  /**
9344
+ * 批量决策状态文案(含"差异已不存在于文件"提示)。
9345
+ * @author ddj 2026年09月15号
9346
+ * @param prefix 动作前缀(已采纳/已不采纳)
9347
+ * @param result 批量决策计数
9348
+ * @returns 状态栏文案
9349
+ */
9350
+ const decideStatus = (prefix, result) => {
9351
+ const text = prefix + result.ok + " 处差异" + (result.fail ? "," + result.fail + " 处失败" : "");
9352
+ return result.stale ? text + "(其中 " + result.stale + " 处已不存在于文件,未改动)" : text;
9353
+ };
9354
+ /**
8767
9355
  * 决策项构造(与单条 actHunk 的 scope 语义一致:create 记录走 call 作用域)。
8768
9356
  * @author ddj 2026年08月25号
8769
9357
  * @param r 差异区域/待处理项(callId/idx/create)
@@ -8777,27 +9365,27 @@ window.__ModuleLoader__.load({
8777
9365
  decision: reject ? "rejected" : "accepted"
8778
9366
  });
8779
9367
  const acceptFile = () => {
8780
- if (batchBusyRef.current || !pendingRegions.length) return;
9368
+ if (batchBusyRef.current || !decideRegions.length) return;
8781
9369
  batchBusyRef.current = true;
8782
- actMany(pendingRegions.map((r) => itemOf(r, false))).then(({ ok, fail }) => {
9370
+ actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
8783
9371
  batchBusyRef.current = false;
8784
9372
  reloadFile(true);
8785
9373
  emitRefresh();
8786
- setStatus("已采纳 " + ok + " 处差异" + (fail ? "," + fail + " 处失败" : ""));
8787
- if (fail) setError(fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
9374
+ setStatus(decideStatus("已采纳 ", result));
9375
+ if (result.fail) setError(result.fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
8788
9376
  }).catch(() => {
8789
9377
  batchBusyRef.current = false;
8790
9378
  });
8791
9379
  };
8792
9380
  const undoFile = () => {
8793
- if (batchBusyRef.current || !pendingRegions.length) return;
9381
+ if (batchBusyRef.current || !decideRegions.length) return;
8794
9382
  batchBusyRef.current = true;
8795
- actMany([...pendingRegions].reverse().map((r) => itemOf(r, true))).then(({ ok, fail }) => {
9383
+ actMany([...decideRegions].reverse().map((r) => itemOf(r, true))).then((result) => {
8796
9384
  batchBusyRef.current = false;
8797
9385
  reloadFile(true);
8798
9386
  emitRefresh();
8799
- setStatus("已不采纳 " + ok + " 处差异" + (fail ? "," + fail + " 处失败" : ""));
8800
- if (fail) setError(fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
9387
+ setStatus(decideStatus("已不采纳 ", result));
9388
+ if (result.fail) setError(result.fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
8801
9389
  }).catch(() => {
8802
9390
  batchBusyRef.current = false;
8803
9391
  });
@@ -8826,12 +9414,12 @@ window.__ModuleLoader__.load({
8826
9414
  if (batchBusyRef.current || !allPending.length) return;
8827
9415
  batchBusyRef.current = true;
8828
9416
  const list = reject ? [...allPending].sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx) : allPending;
8829
- actMany(list.map((r) => itemOf(r, reject))).then(({ ok, fail }) => {
9417
+ actMany(list.map((r) => itemOf(r, reject))).then((result) => {
8830
9418
  batchBusyRef.current = false;
8831
9419
  reloadFile(true);
8832
9420
  emitRefresh();
8833
- setStatus((reject ? "已不采纳 " : "已采纳 ") + ok + " 处差异" + (fail ? "," + fail + " 处失败" : ""));
8834
- if (fail) setError(fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
9421
+ setStatus(decideStatus(reject ? "已不采纳 " : "已采纳 ", result));
9422
+ if (result.fail) setError(result.fail + " 处差异处理失败(可能已被后续修改影响),可刷新后重试");
8835
9423
  }).catch(() => {
8836
9424
  batchBusyRef.current = false;
8837
9425
  });
@@ -9179,7 +9767,20 @@ window.__ModuleLoader__.load({
9179
9767
  },
9180
9768
  title: aiState === "error" && aiNote ? aiNote : "AI 自动补全(设置页「AI 补全」可配置)"
9181
9769
  }, react.default.createElement("span", { className: "edrv-lsp-status-dot " + (!aiEnabled ? "idle" : aiDotCls) }), react.default.createElement("span", { className: "edrv-sp-lsp" }, aiLabel)) : null;
9182
- const statusBar = lspSeg || aiSeg ? react.default.createElement("div", { className: "edrv-statusbar" }, lspSeg, aiSeg) : null;
9770
+ const diskSeg = active && diskFlag && sameFile(diskFlag.path, active) ? react.default.createElement("span", {
9771
+ className: "edrv-status-seg",
9772
+ style: {
9773
+ display: "inline-flex",
9774
+ alignItems: "center",
9775
+ gap: "6px",
9776
+ minWidth: 0,
9777
+ flex: "0 0 auto",
9778
+ cursor: "pointer"
9779
+ },
9780
+ title: "磁盘内容已被外部修改:重新加载 = 取磁盘版本;保留本地 = 继续编辑(保存时会被版本守卫拒绝)",
9781
+ onClick: () => reloadFile()
9782
+ }, react.default.createElement("span", { className: "edrv-sp-lsp" }, diskFlag.kind === "deleted" ? "⚠ 外部已删除" : "⚠ 外部已修改")) : null;
9783
+ const statusBar = lspSeg || aiSeg || diskSeg ? react.default.createElement("div", { className: "edrv-statusbar" }, lspSeg, diskSeg, aiSeg) : null;
9183
9784
  const navBackTarget = navRef.current.peekBack();
9184
9785
  const navForwardTarget = navRef.current.peekForward();
9185
9786
  const navNameOf = (entry) => entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : "";
@@ -9216,6 +9817,43 @@ window.__ModuleLoader__.load({
9216
9817
  sessionId,
9217
9818
  onOpen: (p) => openFile(p, false)
9218
9819
  }));
9820
+ /**
9821
+ * 外部同步提示条(冲突 / 文件被删):仅在提示对象就是当前活动文件时显示。
9822
+ * 不复用 error 面板:内容照常可编辑,提示条只是把「磁盘已变、怎么处理」摆在眼前。
9823
+ * @author ddj 2026年09月15号
9824
+ * @returns 提示条元素或 null
9825
+ */
9826
+ const diskBanner = () => {
9827
+ const flag = diskFlag && sameFile(diskFlag.path, active) ? diskFlag : null;
9828
+ if (!flag) return null;
9829
+ const deleted = flag.kind === "deleted";
9830
+ const text = deleted ? "该文件已被外部删除(缓冲内容保留,保存会失败)" : "磁盘内容已被外部修改(当前有未保存编辑,未覆盖)";
9831
+ return react.default.createElement("div", {
9832
+ className: "edrv-diskbar",
9833
+ style: {
9834
+ display: "flex",
9835
+ alignItems: "center",
9836
+ gap: "8px",
9837
+ flexShrink: 0,
9838
+ padding: "4px 8px",
9839
+ fontSize: "12px",
9840
+ background: "var(--dsw-alias-bg-layer-1, rgba(255,193,7,.12))",
9841
+ borderBottom: "1px solid var(--dsw-alias-border-l1, rgba(255,193,7,.35))"
9842
+ }
9843
+ }, react.default.createElement("span", { title: flag.path }, "⚠ " + text), react.default.createElement("span", { style: { flex: 1 } }), react.default.createElement("button", {
9844
+ className: "edrv-pill edrv-pill-keep",
9845
+ title: "放弃缓冲内容,重新加载磁盘版本",
9846
+ onClick: () => reloadFile()
9847
+ }, "重新加载"), deleted ? null : react.default.createElement("button", {
9848
+ className: "edrv-pill edrv-pill-undo",
9849
+ title: "用编辑器内容覆盖磁盘(放弃磁盘上的外部修改)",
9850
+ onClick: overwriteDisk
9851
+ }, "覆盖磁盘"), deleted ? null : react.default.createElement("button", {
9852
+ className: "edrv-pill edrv-pill-ghost",
9853
+ title: "保留编辑器内容,不取用磁盘版本",
9854
+ onClick: keepLocal
9855
+ }, "保留本地"));
9856
+ };
9219
9857
  const otherFiles = sum.pendingFiles.filter((f) => f.path !== active);
9220
9858
  /**
9221
9859
  * 渲染编辑器/文件加载进度面板。
@@ -9443,7 +10081,7 @@ window.__ModuleLoader__.load({
9443
10081
  flexDirection: "column",
9444
10082
  overflow: "hidden"
9445
10083
  }
9446
- }, pathBar, tabRow, sideHintEl, editorArea, statusBar);
10084
+ }, pathBar, tabRow, sideHintEl, diskBanner(), editorArea, statusBar);
9447
10085
  const editorRow = react.default.createElement("div", { className: "edrv-editor-row" }, sidebarPanels ? react.default.createElement(SidebarView, {
9448
10086
  registry: sidebarPanels,
9449
10087
  ctx: sidebarCtx,
@@ -9668,6 +10306,7 @@ window.__ModuleLoader__.load({
9668
10306
  const [detailsOpen, setDetailsOpen] = react.default.useState(false);
9669
10307
  const detailsId = react.default.useId();
9670
10308
  const canAct = pendingRegions.length > 0;
10309
+ const canDecide = canDecideFile(pendingRegions.length, staleRegions.length);
9671
10310
  const base = String(activePath || "").split(/[\\/]/).pop() || "";
9672
10311
  if (mode === "chat" || mode === "editor-empty") return react.default.createElement("div", {
9673
10312
  className: "edrv-diffbar edrv-diffbar-dock",
@@ -9768,12 +10407,12 @@ window.__ModuleLoader__.load({
9768
10407
  }, base)), react.default.createElement("div", { className: "edrv-diffbar-actions" }, react.default.createElement("button", {
9769
10408
  className: "edrv-pill edrv-pill-keep",
9770
10409
  title: "采纳当前文件的全部差异",
9771
- disabled: !canAct,
10410
+ disabled: !canDecide,
9772
10411
  onClick: onAcceptFile
9773
10412
  }, "✓ Keep"), react.default.createElement("button", {
9774
10413
  className: "edrv-pill edrv-pill-undo",
9775
10414
  title: "不采纳当前文件的全部差异(回滚)",
9776
- disabled: !canAct,
10415
+ disabled: !canDecide,
9777
10416
  onClick: onUndoFile
9778
10417
  }, "↩ Undo"), react.default.createElement("button", {
9779
10418
  className: "edrv-pill edrv-pill-ghost edrv-diff-details-toggle",
@@ -13546,6 +14185,8 @@ window.__ModuleLoader__.load({
13546
14185
  const REVEAL_HIGHLIGHT_MS = 2e3;
13547
14186
  const REVEAL_RETRY_MAX = 6;
13548
14187
  const REVEAL_RETRY_MS = 120;
14188
+ /** 外部文件变化重列去抖:一轮外部批量写入(如 agent 连写多文件)合并为一次重列。 */
14189
+ const FILE_CHANGE_DEBOUNCE_MS = 400;
13549
14190
  /**
13550
14191
  * 目录图标元素(官方 IconFolderOpen16/Close16)。
13551
14192
  * @author ddj 2026年09月10号
@@ -13639,6 +14280,11 @@ window.__ModuleLoader__.load({
13639
14280
  const revealTryRef = react.default.useRef(0);
13640
14281
  const revealInTreeRef = react.default.useRef(null);
13641
14282
  const treeRef = react.default.useRef(null);
14283
+ const reloadDirRef = react.default.useRef(null);
14284
+ const changedTimerRef = react.default.useRef(null);
14285
+ const changedRelRef = react.default.useRef(/* @__PURE__ */ new Set());
14286
+ const dirsMapRef = react.default.useRef(null);
14287
+ dirsMapRef.current = loadDir;
13642
14288
  /** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
13643
14289
  const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(scope, rel) ?? null;
13644
14290
  /** 预取子目录:≤4 个、排除重型目录、缓存新鲜跳过、已加载跳过、不级联。 */
@@ -13764,6 +14410,45 @@ window.__ModuleLoader__.load({
13764
14410
  });
13765
14411
  };
13766
14412
  refreshRef.current = refresh;
14413
+ reloadDirRef.current = loadDir;
14414
+ /**
14415
+ * 磁盘文件变化(外部写入/删除/改名):把变化路径的父目录并入待重列集合,
14416
+ * 去抖合并后对「已展开且存在」的目录强制重列(force 跳过 host 索引命中)。
14417
+ * host 侧 fileVersions 已顺手失效目录树缓存,这里补上「已渲染行」的即时刷新。
14418
+ * @author ddj 2026年09月15号
14419
+ * @param path 发生变化的文件路径(工作区相对;绝对路径按最长已展开祖先匹配)
14420
+ */
14421
+ const onFileChanged = (path) => {
14422
+ if (typeof path !== "string" || !path) return;
14423
+ const rel = path.replace(/\\/g, "/").replace(/^\.\//, "");
14424
+ const parts = rel.split("/").filter(Boolean);
14425
+ if (parts.length > 1) changedRelRef.current.add(parts.slice(0, -1).join("/"));
14426
+ for (const dir of ancestorDirsOf(rel)) if (expandedRef.current[dir] === true) changedRelRef.current.add(dir);
14427
+ if (changedTimerRef.current) return;
14428
+ changedTimerRef.current = window.setTimeout(() => {
14429
+ changedTimerRef.current = null;
14430
+ const targets = [...changedRelRef.current];
14431
+ changedRelRef.current.clear();
14432
+ for (const dir of targets) {
14433
+ if (dir !== "" && expandedRef.current[dir] !== true) continue;
14434
+ reloadDirRef.current?.(dir, {
14435
+ force: true,
14436
+ prefetch: false
14437
+ });
14438
+ }
14439
+ }, FILE_CHANGE_DEBOUNCE_MS);
14440
+ };
14441
+ react.default.useEffect(() => {
14442
+ const handler = (event) => onFileChanged(event?.detail?.path);
14443
+ window.addEventListener("edrv:file-changed", handler);
14444
+ return () => {
14445
+ window.removeEventListener("edrv:file-changed", handler);
14446
+ if (changedTimerRef.current) {
14447
+ clearTimeout(changedTimerRef.current);
14448
+ changedTimerRef.current = null;
14449
+ }
14450
+ };
14451
+ }, []);
13767
14452
  react.default.useEffect(() => {
13768
14453
  tokensRef.current = {};
13769
14454
  setDirs({});