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/README.md +22 -1
- package/lib/client.js +729 -44
- package/lib/client.js.map +1 -1
- package/lib/index.js +261 -33
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/diffDock.ts +12 -0
- package/src/client/events.ts +11 -0
- package/src/client/sidebar/panels/FileExplorer.ts +47 -0
- package/src/client/ui/DiffBox.ts +5 -3
- package/src/client/ui/EditorView.ts +317 -41
- 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/revert.ts +4 -2
- package/src/rpc.ts +62 -12
- package/src/shared/rpc.ts +25 -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
|
/**
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
8676
|
+
savingRef.current.add(path);
|
|
8677
|
+
const rev = revRef.current[path];
|
|
8208
8678
|
rpc("edrv.save", {
|
|
8209
8679
|
sessionId,
|
|
8210
|
-
path
|
|
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
|
-
|
|
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(
|
|
8217
|
-
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
|
+
}
|
|
8218
8696
|
refreshRecords();
|
|
8219
8697
|
emitRefresh();
|
|
8220
|
-
if (/\.code-snippets$/i.test(
|
|
8221
|
-
|
|
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(
|
|
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
|
-
*
|
|
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)
|
|
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
|
-
|
|
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 || !
|
|
9368
|
+
if (batchBusyRef.current || !decideRegions.length) return;
|
|
8781
9369
|
batchBusyRef.current = true;
|
|
8782
|
-
actMany(
|
|
9370
|
+
actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
|
|
8783
9371
|
batchBusyRef.current = false;
|
|
8784
9372
|
reloadFile(true);
|
|
8785
9373
|
emitRefresh();
|
|
8786
|
-
setStatus("已采纳 "
|
|
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 || !
|
|
9381
|
+
if (batchBusyRef.current || !decideRegions.length) return;
|
|
8794
9382
|
batchBusyRef.current = true;
|
|
8795
|
-
actMany([...
|
|
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("已不采纳 "
|
|
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((
|
|
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 ? "已不采纳 " : "已采纳 "
|
|
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
|
|
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: !
|
|
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: !
|
|
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({});
|