dsh-vscode-mode 0.4.3 → 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/README.md +18 -1
- package/lib/client.js +681 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +240 -21
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/events.ts +11 -0
- package/src/client/sidebar/panels/FileExplorer.ts +47 -0
- package/src/client/ui/EditorView.ts +285 -25
- package/src/client/ui/useFileWatch.ts +214 -0
- package/src/client/watchDecision.ts +274 -0
- package/src/compat.ts +1 -1
- package/src/dshVersion.ts +42 -10
- package/src/fileOpenSettings.ts +26 -2
- package/src/fileVersions.ts +167 -0
- package/src/index.ts +6 -1
- package/src/rpc.ts +50 -7
- package/src/shared/rpc.ts +23 -3
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
|
/**
|
|
@@ -7188,6 +7540,8 @@ window.__ModuleLoader__.load({
|
|
|
7188
7540
|
activeRef.current = active;
|
|
7189
7541
|
const tabsRef = react.default.useRef([]);
|
|
7190
7542
|
tabsRef.current = tabs;
|
|
7543
|
+
const tabPathsRef = react.default.useRef([]);
|
|
7544
|
+
tabPathsRef.current = tabs.map((t) => t.path);
|
|
7191
7545
|
const dirtyRef = react.default.useRef({});
|
|
7192
7546
|
dirtyRef.current = dirtyMap;
|
|
7193
7547
|
const tabsHostRef = react.default.useRef(null);
|
|
@@ -7206,6 +7560,9 @@ window.__ModuleLoader__.load({
|
|
|
7206
7560
|
const doSaveRef = react.default.useRef(null);
|
|
7207
7561
|
const onEditRef = react.default.useRef(null);
|
|
7208
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);
|
|
7209
7566
|
const diffRendererRef = react.default.useRef(null);
|
|
7210
7567
|
const layoutRef = react.default.useRef(layout);
|
|
7211
7568
|
layoutRef.current = layout;
|
|
@@ -7389,6 +7746,10 @@ window.__ModuleLoader__.load({
|
|
|
7389
7746
|
pdfCtlRef.current.delete(path);
|
|
7390
7747
|
}
|
|
7391
7748
|
pdfB64CacheRef.current.delete(path);
|
|
7749
|
+
clearBaseline(scope, path);
|
|
7750
|
+
clearReadVersion(scope, path);
|
|
7751
|
+
clearSync(scope, path);
|
|
7752
|
+
delete revRef.current[path];
|
|
7392
7753
|
};
|
|
7393
7754
|
/**
|
|
7394
7755
|
* 提交关闭结果:一次性更新页签与活动页签(补位规则见 tabActions.applyClose)。
|
|
@@ -7480,19 +7841,33 @@ window.__ModuleLoader__.load({
|
|
|
7480
7841
|
/**
|
|
7481
7842
|
* 加载文件内容(对齐 VSCode model 复用:会话内已打开的 model 直接秒显,
|
|
7482
7843
|
* 后台静默 RPC 校验防陈旧;首次打开走原读取流程)。
|
|
7483
|
-
*
|
|
7844
|
+
* 每条成功分支都记下磁盘版本基线:外部改动轮询据此判断缓冲是否已陈旧,
|
|
7845
|
+
* 保存时作为版本守卫令牌随 edrv.save 回传(读后被外部改过则拒绝写入)。
|
|
7846
|
+
* @author ddj 2026年08月28号 / 2026年09月15号
|
|
7484
7847
|
* @param path 文件路径
|
|
7485
7848
|
* @param sid 会话 id
|
|
7486
7849
|
* @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
|
|
7850
|
+
* @param version 已知磁盘版本(缺省走 RPC 返回值)
|
|
7487
7851
|
*/
|
|
7488
|
-
const loadContent = (path, sid, force) => {
|
|
7852
|
+
const loadContent = (path, sid, force, version) => {
|
|
7489
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
|
+
};
|
|
7490
7865
|
if (isImagePath(path) && !svgTextRef.current.has(path)) {
|
|
7491
|
-
loadImage(path, sid, seq);
|
|
7866
|
+
loadImage(path, sid, seq, version);
|
|
7492
7867
|
return;
|
|
7493
7868
|
}
|
|
7494
7869
|
if (isPdfPath(path)) {
|
|
7495
|
-
loadPdf(path, sid, seq, force === true);
|
|
7870
|
+
loadPdf(path, sid, seq, force === true, version);
|
|
7496
7871
|
return;
|
|
7497
7872
|
}
|
|
7498
7873
|
const cachedModel = force ? null : modelsRef.current.get(path);
|
|
@@ -7509,6 +7884,7 @@ window.__ModuleLoader__.load({
|
|
|
7509
7884
|
path
|
|
7510
7885
|
}).then((res) => {
|
|
7511
7886
|
if (seq !== loadSeqRef.current || path !== active) return;
|
|
7887
|
+
if (res && res.ok) mark(res.version);
|
|
7512
7888
|
if (res && res.ok && res.content !== cachedModel.getValue()) {
|
|
7513
7889
|
saveViewState(path);
|
|
7514
7890
|
setContent(res.content);
|
|
@@ -7528,6 +7904,7 @@ window.__ModuleLoader__.load({
|
|
|
7528
7904
|
}).then((res) => {
|
|
7529
7905
|
if (seq !== loadSeqRef.current || path !== active) return;
|
|
7530
7906
|
if (res && res.ok) {
|
|
7907
|
+
mark(res.version, true);
|
|
7531
7908
|
setContent(res.content);
|
|
7532
7909
|
setContentPath(path);
|
|
7533
7910
|
setLoadStage((prev) => ({
|
|
@@ -7552,12 +7929,13 @@ window.__ModuleLoader__.load({
|
|
|
7552
7929
|
};
|
|
7553
7930
|
/**
|
|
7554
7931
|
* 加载图片文件为 data URL(编辑区只读预览);失败走 loadError 面板(重试经 loadContent 分派回此)。
|
|
7555
|
-
* @author ddj 2026年09月08号
|
|
7932
|
+
* @author ddj 2026年09月08号 / 2026年09月15号
|
|
7556
7933
|
* @param path 图片文件路径
|
|
7557
7934
|
* @param sid 会话 id
|
|
7558
7935
|
* @param seq 加载序号(过期响应丢弃)
|
|
7936
|
+
* @param version 已知磁盘版本(图片同走外部改动轮询,读到新版本即重取)
|
|
7559
7937
|
*/
|
|
7560
|
-
const loadImage = (path, sid, seq) => {
|
|
7938
|
+
const loadImage = (path, sid, seq, version) => {
|
|
7561
7939
|
setImageSrc(null);
|
|
7562
7940
|
setImgSize(null);
|
|
7563
7941
|
setImgBroken(false);
|
|
@@ -7578,6 +7956,9 @@ window.__ModuleLoader__.load({
|
|
|
7578
7956
|
setStatus("读取失败");
|
|
7579
7957
|
return;
|
|
7580
7958
|
}
|
|
7959
|
+
const imgVersion = res.version ?? version;
|
|
7960
|
+
recordBaseline(scope, path, imgVersion);
|
|
7961
|
+
clearReadVersion(scope, path);
|
|
7581
7962
|
setImageSrc(dataUrlOf(res.content, res.mime));
|
|
7582
7963
|
setLoadStage({
|
|
7583
7964
|
progress: 100,
|
|
@@ -7607,8 +7988,9 @@ window.__ModuleLoader__.load({
|
|
|
7607
7988
|
* @param sid 会话 id
|
|
7608
7989
|
* @param seq 加载序号(过期响应丢弃)
|
|
7609
7990
|
* @param force true=绕过缓存强制 RPC 重读(刷新按钮)
|
|
7991
|
+
* @param version 已知磁盘版本(外部改动轮询触发时带入)
|
|
7610
7992
|
*/
|
|
7611
|
-
const loadPdf = (path, sid, seq, force) => {
|
|
7993
|
+
const loadPdf = (path, sid, seq, force, version) => {
|
|
7612
7994
|
setPdfBytes(null);
|
|
7613
7995
|
setLoadStage({
|
|
7614
7996
|
progress: monaco ? 72 : 12,
|
|
@@ -7634,6 +8016,9 @@ window.__ModuleLoader__.load({
|
|
|
7634
8016
|
}).then((res) => {
|
|
7635
8017
|
if (seq !== loadSeqRef.current || path !== active) return;
|
|
7636
8018
|
if (res && res.ok && res.encoding === "base64") {
|
|
8019
|
+
const pdfVersion = res.version ?? version;
|
|
8020
|
+
recordBaseline(scope, path, pdfVersion);
|
|
8021
|
+
clearReadVersion(scope, path);
|
|
7637
8022
|
cache.set(path, res.content);
|
|
7638
8023
|
while (cache.size > 6) cache.delete(cache.keys().next().value);
|
|
7639
8024
|
setPdfBytes(base64ToBytes(res.content));
|
|
@@ -7668,6 +8053,66 @@ window.__ModuleLoader__.load({
|
|
|
7668
8053
|
window.removeEventListener("edrv:refresh", onRefresh);
|
|
7669
8054
|
};
|
|
7670
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
|
+
});
|
|
7671
8116
|
react.default.useEffect(() => {
|
|
7672
8117
|
const publishLsp = (servers) => setLspServers(Array.isArray(servers) ? servers : []);
|
|
7673
8118
|
const unsubscribe = onLspProgress(publishLsp);
|
|
@@ -8188,13 +8633,24 @@ window.__ModuleLoader__.load({
|
|
|
8188
8633
|
* 提交待执行的防抖保存(**真正执行保存**,不是取消)。
|
|
8189
8634
|
* 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
|
|
8190
8635
|
* 旧实现把它当「立即保存」调用 → 防抖窗口内切页签/关闭文件会静默丢改动。
|
|
8191
|
-
*
|
|
8636
|
+
* 外部改动待处理(冲突/文件被删)时跳过:切页签/卸载不得用陈旧缓冲覆盖磁盘,
|
|
8637
|
+
* 缓冲内容仍在 model 里,用户处理完冲突后再保存。
|
|
8638
|
+
* @author ddj 2026年09月11号 / 2026年09月15号
|
|
8639
|
+
* @param path 目标路径(缺省 = 当前活动文件)
|
|
8640
|
+
* @returns 是否执行了保存
|
|
8192
8641
|
*/
|
|
8193
|
-
const flushSave = () => {
|
|
8642
|
+
const flushSave = (path) => {
|
|
8643
|
+
const target = path ?? active;
|
|
8644
|
+
if (target && readSync(scope, target)) return false;
|
|
8194
8645
|
saveTimerRef.current?.flush();
|
|
8646
|
+
return true;
|
|
8195
8647
|
};
|
|
8196
8648
|
const doSave = (silent) => {
|
|
8197
8649
|
if (!active) return;
|
|
8650
|
+
if (silent && readSync(scope, active)) {
|
|
8651
|
+
setStatus("外部已修改(未保存的编辑保留)");
|
|
8652
|
+
return;
|
|
8653
|
+
}
|
|
8198
8654
|
if (isPdfPath(active)) {
|
|
8199
8655
|
const ctl = pdfCtlRef.current.get(active);
|
|
8200
8656
|
if (!ctl) {
|
|
@@ -8215,30 +8671,49 @@ window.__ModuleLoader__.load({
|
|
|
8215
8671
|
return;
|
|
8216
8672
|
}
|
|
8217
8673
|
const text = ed.getValue();
|
|
8674
|
+
const path = active;
|
|
8218
8675
|
if (!silent) setStatus("保存中…");
|
|
8219
|
-
savingRef.current.add(
|
|
8676
|
+
savingRef.current.add(path);
|
|
8677
|
+
const rev = revRef.current[path];
|
|
8220
8678
|
rpc("edrv.save", {
|
|
8221
8679
|
sessionId,
|
|
8222
|
-
path
|
|
8223
|
-
content: text
|
|
8680
|
+
path,
|
|
8681
|
+
content: text,
|
|
8682
|
+
rev: typeof rev === "string" && rev ? rev : void 0
|
|
8224
8683
|
}).then((res) => {
|
|
8225
8684
|
if (res && res.ok) {
|
|
8226
|
-
|
|
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);
|
|
8227
8689
|
setContent(text);
|
|
8228
|
-
setContentPath(
|
|
8229
|
-
setDirtyMap((d) => Object.assign({}, d, { [
|
|
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
|
+
}
|
|
8230
8696
|
refreshRecords();
|
|
8231
8697
|
emitRefresh();
|
|
8232
|
-
if (/\.code-snippets$/i.test(
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8698
|
+
if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent("edrv:snippets-changed"));
|
|
8699
|
+
return;
|
|
8700
|
+
}
|
|
8701
|
+
if (res && res.conflict) {
|
|
8702
|
+
markDiskFlag({
|
|
8703
|
+
path,
|
|
8704
|
+
kind: "conflict",
|
|
8705
|
+
at: Date.now()
|
|
8706
|
+
});
|
|
8707
|
+
setStatus("保存被拒:文件已被外部修改");
|
|
8708
|
+
return;
|
|
8236
8709
|
}
|
|
8710
|
+
setStatus("保存失败");
|
|
8711
|
+
setError(res?.error ? String(res.error) : "保存失败");
|
|
8237
8712
|
}).catch((e) => {
|
|
8238
8713
|
setStatus("保存失败");
|
|
8239
8714
|
setError("保存异常:" + String(e));
|
|
8240
8715
|
}).finally(() => {
|
|
8241
|
-
savingRef.current.delete(
|
|
8716
|
+
savingRef.current.delete(path);
|
|
8242
8717
|
});
|
|
8243
8718
|
};
|
|
8244
8719
|
doSaveRef.current = doSave;
|
|
@@ -8249,12 +8724,17 @@ window.__ModuleLoader__.load({
|
|
|
8249
8724
|
* 这里再处理**仍标脏**的页签 —— 包括活动页签(其保存可能在途或失败),
|
|
8250
8725
|
* 用 model 里的当前文本补一次,保证关闭前内容一定写到磁盘。
|
|
8251
8726
|
* 保存失败只保留脏标记(不丢用户编辑),并在状态栏给出提示。
|
|
8252
|
-
*
|
|
8727
|
+
* 外部改动待处理的页签跳过(版本守卫下必被拒绝;缓冲仍在 model 里,不丢内容)。
|
|
8728
|
+
* @author ddj 2026年09月11号 / 2026年09月15号
|
|
8253
8729
|
* @param paths 即将关闭的页签路径
|
|
8254
8730
|
*/
|
|
8255
8731
|
const persistDirty = (paths) => {
|
|
8256
8732
|
for (const path of paths) {
|
|
8257
8733
|
if (!dirtyRef.current[path]) continue;
|
|
8734
|
+
if (readSync(scope, path)) {
|
|
8735
|
+
setStatus("未落盘(外部已修改):" + baseNameOf(path));
|
|
8736
|
+
continue;
|
|
8737
|
+
}
|
|
8258
8738
|
if (savingRef.current.has(path)) continue;
|
|
8259
8739
|
const pdfCtl = pdfCtlRef.current.get(path);
|
|
8260
8740
|
if (pdfCtl) {
|
|
@@ -8268,12 +8748,19 @@ window.__ModuleLoader__.load({
|
|
|
8268
8748
|
}
|
|
8269
8749
|
const content = model.getValue();
|
|
8270
8750
|
savingRef.current.add(path);
|
|
8751
|
+
const rev = revRef.current[path];
|
|
8271
8752
|
rpc("edrv.save", {
|
|
8272
8753
|
sessionId,
|
|
8273
8754
|
path,
|
|
8274
|
-
content
|
|
8755
|
+
content,
|
|
8756
|
+
rev: typeof rev === "string" && rev ? rev : void 0
|
|
8275
8757
|
}).then((res) => {
|
|
8276
|
-
if (res && res.ok)
|
|
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
|
+
}
|
|
8277
8764
|
}).catch((e) => dbg(sessionId, "关闭前落盘失败:" + path + " · " + String(e))).finally(() => {
|
|
8278
8765
|
savingRef.current.delete(path);
|
|
8279
8766
|
});
|
|
@@ -8283,6 +8770,10 @@ window.__ModuleLoader__.load({
|
|
|
8283
8770
|
if (!editorRef.current || !active) return;
|
|
8284
8771
|
setDirtyMap((d) => Object.assign({}, d, { [active]: true }));
|
|
8285
8772
|
setStatus("编辑中…");
|
|
8773
|
+
if (readSync(scope, active)) {
|
|
8774
|
+
setStatus("外部已修改(未保存的编辑保留)");
|
|
8775
|
+
return;
|
|
8776
|
+
}
|
|
8286
8777
|
saveTimerRef.current?.arm(schedule, 700, () => doSave(true));
|
|
8287
8778
|
};
|
|
8288
8779
|
onEditRef.current = onEdit;
|
|
@@ -8634,12 +9125,80 @@ window.__ModuleLoader__.load({
|
|
|
8634
9125
|
if (sum.pendingFiles.some((file) => sameFile(file.path, active))) gotoFile(1);
|
|
8635
9126
|
else openFile(sum.pendingFiles[0].path, true);
|
|
8636
9127
|
};
|
|
9128
|
+
/**
|
|
9129
|
+
* 重新从磁盘加载当前文件(工具栏 ⟳ / 冲突提示「重新加载」/ 差异决策后刷新)。
|
|
9130
|
+
* 一律强制重读(跳过 model 复用):这才是「用户要看到磁盘真实内容」的语义。
|
|
9131
|
+
* @author ddj 2026年09月15号
|
|
9132
|
+
* @param skipStale 差异记录刷新是否跳过 stale 清理
|
|
9133
|
+
*/
|
|
8637
9134
|
const reloadFile = (skipStale) => {
|
|
8638
9135
|
if (!active) return;
|
|
8639
|
-
|
|
9136
|
+
const path = active;
|
|
9137
|
+
loadContent(path, sessionId, true);
|
|
9138
|
+
clearSync(scope, path);
|
|
9139
|
+
setDiskFlag((prev) => prev && prev.path === path ? null : prev);
|
|
8640
9140
|
refreshRecords(skipStale === true);
|
|
8641
9141
|
};
|
|
8642
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
|
+
/**
|
|
8643
9202
|
* SVG 在图片预览与文本编辑间切换(按路径记忆;重载分派随集合状态自动路由)。
|
|
8644
9203
|
* @author ddj 2026年09月08号
|
|
8645
9204
|
* @param path SVG 文件路径
|
|
@@ -9208,7 +9767,20 @@ window.__ModuleLoader__.load({
|
|
|
9208
9767
|
},
|
|
9209
9768
|
title: aiState === "error" && aiNote ? aiNote : "AI 自动补全(设置页「AI 补全」可配置)"
|
|
9210
9769
|
}, react.default.createElement("span", { className: "edrv-lsp-status-dot " + (!aiEnabled ? "idle" : aiDotCls) }), react.default.createElement("span", { className: "edrv-sp-lsp" }, aiLabel)) : null;
|
|
9211
|
-
const
|
|
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;
|
|
9212
9784
|
const navBackTarget = navRef.current.peekBack();
|
|
9213
9785
|
const navForwardTarget = navRef.current.peekForward();
|
|
9214
9786
|
const navNameOf = (entry) => entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : "";
|
|
@@ -9245,6 +9817,43 @@ window.__ModuleLoader__.load({
|
|
|
9245
9817
|
sessionId,
|
|
9246
9818
|
onOpen: (p) => openFile(p, false)
|
|
9247
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
|
+
};
|
|
9248
9857
|
const otherFiles = sum.pendingFiles.filter((f) => f.path !== active);
|
|
9249
9858
|
/**
|
|
9250
9859
|
* 渲染编辑器/文件加载进度面板。
|
|
@@ -9472,7 +10081,7 @@ window.__ModuleLoader__.load({
|
|
|
9472
10081
|
flexDirection: "column",
|
|
9473
10082
|
overflow: "hidden"
|
|
9474
10083
|
}
|
|
9475
|
-
}, pathBar, tabRow, sideHintEl, editorArea, statusBar);
|
|
10084
|
+
}, pathBar, tabRow, sideHintEl, diskBanner(), editorArea, statusBar);
|
|
9476
10085
|
const editorRow = react.default.createElement("div", { className: "edrv-editor-row" }, sidebarPanels ? react.default.createElement(SidebarView, {
|
|
9477
10086
|
registry: sidebarPanels,
|
|
9478
10087
|
ctx: sidebarCtx,
|
|
@@ -13576,6 +14185,8 @@ window.__ModuleLoader__.load({
|
|
|
13576
14185
|
const REVEAL_HIGHLIGHT_MS = 2e3;
|
|
13577
14186
|
const REVEAL_RETRY_MAX = 6;
|
|
13578
14187
|
const REVEAL_RETRY_MS = 120;
|
|
14188
|
+
/** 外部文件变化重列去抖:一轮外部批量写入(如 agent 连写多文件)合并为一次重列。 */
|
|
14189
|
+
const FILE_CHANGE_DEBOUNCE_MS = 400;
|
|
13579
14190
|
/**
|
|
13580
14191
|
* 目录图标元素(官方 IconFolderOpen16/Close16)。
|
|
13581
14192
|
* @author ddj 2026年09月10号
|
|
@@ -13669,6 +14280,11 @@ window.__ModuleLoader__.load({
|
|
|
13669
14280
|
const revealTryRef = react.default.useRef(0);
|
|
13670
14281
|
const revealInTreeRef = react.default.useRef(null);
|
|
13671
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;
|
|
13672
14288
|
/** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
|
|
13673
14289
|
const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(scope, rel) ?? null;
|
|
13674
14290
|
/** 预取子目录:≤4 个、排除重型目录、缓存新鲜跳过、已加载跳过、不级联。 */
|
|
@@ -13794,6 +14410,45 @@ window.__ModuleLoader__.load({
|
|
|
13794
14410
|
});
|
|
13795
14411
|
};
|
|
13796
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
|
+
}, []);
|
|
13797
14452
|
react.default.useEffect(() => {
|
|
13798
14453
|
tokensRef.current = {};
|
|
13799
14454
|
setDirs({});
|