dsh-vscode-mode 0.4.3 → 0.4.5
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 +737 -40
- 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/monaco/loader.ts +55 -9
- package/src/client/monaco/lsp/lspClient.ts +5 -1
- package/src/client/sidebar/panels/FileExplorer.ts +47 -0
- package/src/client/ui/EditorView.ts +289 -26
- package/src/client/ui/OfficialSideTab.ts +1 -1
- 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 目标路径
|
|
@@ -1519,7 +1529,7 @@ window.__ModuleLoader__.load({
|
|
|
1519
1529
|
}
|
|
1520
1530
|
/**
|
|
1521
1531
|
* 加载 Monaco Editor(AMD 构建,随插件包离线分发):注入 loader.js → require.config → editor.main。
|
|
1522
|
-
* @author ddj 2026年08月20号
|
|
1532
|
+
* @author ddj 2026年08月20号 / 2026年09月22号
|
|
1523
1533
|
* @returns Promise<object> window.monaco
|
|
1524
1534
|
*/
|
|
1525
1535
|
function loadMonaco(onProgress) {
|
|
@@ -1527,13 +1537,58 @@ window.__ModuleLoader__.load({
|
|
|
1527
1537
|
if (!monacoPromise) {
|
|
1528
1538
|
publishStage("loader", MONACO_STAGES.loader.progress, MONACO_STAGES.loader.message);
|
|
1529
1539
|
monacoPromise = new Promise((resolve, reject) => {
|
|
1540
|
+
const hasModule = Object.prototype.hasOwnProperty.call(globalThis, "module");
|
|
1541
|
+
const hasExports = Object.prototype.hasOwnProperty.call(globalThis, "exports");
|
|
1542
|
+
const savedModule = globalThis.module;
|
|
1543
|
+
const savedExports = globalThis.exports;
|
|
1544
|
+
const hideNodeGlobals = () => {
|
|
1545
|
+
try {
|
|
1546
|
+
delete globalThis.module;
|
|
1547
|
+
} catch (error) {}
|
|
1548
|
+
try {
|
|
1549
|
+
delete globalThis.exports;
|
|
1550
|
+
} catch (error) {}
|
|
1551
|
+
};
|
|
1552
|
+
const restoreNodeGlobals = () => {
|
|
1553
|
+
if (hasModule) globalThis.module = savedModule;
|
|
1554
|
+
else try {
|
|
1555
|
+
delete globalThis.module;
|
|
1556
|
+
} catch (error) {}
|
|
1557
|
+
if (hasExports) globalThis.exports = savedExports;
|
|
1558
|
+
else try {
|
|
1559
|
+
delete globalThis.exports;
|
|
1560
|
+
} catch (error) {}
|
|
1561
|
+
};
|
|
1562
|
+
const removeLoaderTag = () => {
|
|
1563
|
+
const existing = document.querySelector("script[data-edrv-monaco-loader]");
|
|
1564
|
+
if (existing?.parentNode) existing.parentNode.removeChild(existing);
|
|
1565
|
+
};
|
|
1530
1566
|
const fail = (error) => {
|
|
1531
1567
|
monacoPromise = null;
|
|
1568
|
+
removeLoaderTag();
|
|
1569
|
+
restoreNodeGlobals();
|
|
1532
1570
|
publishStage("error", MONACO_STAGES.error.progress, MONACO_STAGES.error.message);
|
|
1533
1571
|
reject(error);
|
|
1534
1572
|
};
|
|
1573
|
+
const inject = () => {
|
|
1574
|
+
const s = document.createElement("script");
|
|
1575
|
+
s.src = MONACO_BASE + "/loader.js";
|
|
1576
|
+
s.dataset.edrvMonacoLoader = "1";
|
|
1577
|
+
s.onload = boot;
|
|
1578
|
+
s.onerror = (event) => {
|
|
1579
|
+
const hint = event && event.type ? "(" + event.type + ")" : "";
|
|
1580
|
+
fail(/* @__PURE__ */ new Error("Monaco loader 加载失败" + hint));
|
|
1581
|
+
};
|
|
1582
|
+
hideNodeGlobals();
|
|
1583
|
+
document.head.appendChild(s);
|
|
1584
|
+
};
|
|
1535
1585
|
const boot = () => {
|
|
1586
|
+
restoreNodeGlobals();
|
|
1536
1587
|
try {
|
|
1588
|
+
if (typeof window.require !== "function" || typeof window.require.config !== "function") {
|
|
1589
|
+
fail(/* @__PURE__ */ new Error("Monaco loader 未挂载 window.require(可能被其他脚本注入的全局 module 干扰)"));
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1537
1592
|
window.require.config({ paths: { vs: MONACO_BASE } });
|
|
1538
1593
|
publishStage("core", MONACO_STAGES.core.progress, MONACO_STAGES.core.message);
|
|
1539
1594
|
window.require(["vs/editor/editor.main"], () => {
|
|
@@ -1548,15 +1603,12 @@ window.__ModuleLoader__.load({
|
|
|
1548
1603
|
fail(error);
|
|
1549
1604
|
}
|
|
1550
1605
|
};
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
s.onerror = () => fail(/* @__PURE__ */ new Error("Monaco loader 加载失败"));
|
|
1558
|
-
document.head.appendChild(s);
|
|
1559
|
-
}
|
|
1606
|
+
const existing = document.querySelector("script[data-edrv-monaco-loader]");
|
|
1607
|
+
if (existing && typeof window.require === "function" && typeof window.require.config === "function") boot();
|
|
1608
|
+
else if (existing) {
|
|
1609
|
+
removeLoaderTag();
|
|
1610
|
+
inject();
|
|
1611
|
+
} else inject();
|
|
1560
1612
|
});
|
|
1561
1613
|
}
|
|
1562
1614
|
return monacoPromise.finally(unsubscribe);
|
|
@@ -1640,6 +1692,348 @@ window.__ModuleLoader__.load({
|
|
|
1640
1692
|
for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
1641
1693
|
return btoa(bin);
|
|
1642
1694
|
}
|
|
1695
|
+
/** 作用域键 → 路径 → 磁盘版本令牌。 */
|
|
1696
|
+
const baselines = /* @__PURE__ */ new Map();
|
|
1697
|
+
/** 作用域键 → 路径 → 已读取过内容的磁盘版本(同版本不重复读盘;含当时的比对结果)。 */
|
|
1698
|
+
const readVersions = /* @__PURE__ */ new Map();
|
|
1699
|
+
/** 作用域键 → 路径 → 待处理同步标记。 */
|
|
1700
|
+
const syncState = /* @__PURE__ */ new Map();
|
|
1701
|
+
/**
|
|
1702
|
+
* 判定一次观测的同步动作(纯函数,判定表见模块头注释)。
|
|
1703
|
+
* @author ddj 2026年09月15号
|
|
1704
|
+
* @param input 观测输入(基线有无/版本是否变化/是否缺失/缓冲是否脏/内容是否相同)
|
|
1705
|
+
* @returns 同步动作
|
|
1706
|
+
*/
|
|
1707
|
+
function syncDecision(input) {
|
|
1708
|
+
if (!input.hasBaseline) return "none";
|
|
1709
|
+
if (input.missing) return input.clean ? "deleted" : "none";
|
|
1710
|
+
if (!input.versionChanged) return "none";
|
|
1711
|
+
if (input.clean) return "sync-silent";
|
|
1712
|
+
return input.contentEqual ? "sync-silent" : "conflict";
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* 从 host 版本条目取可比较的版本令牌(缺失/空串 → null,表示该后端不提供版本)。
|
|
1716
|
+
* @author ddj 2026年09月15号
|
|
1717
|
+
* @param item 版本条目(可为 undefined)
|
|
1718
|
+
* @returns 版本令牌或 null
|
|
1719
|
+
*/
|
|
1720
|
+
function versionOf(item) {
|
|
1721
|
+
const version = item?.version;
|
|
1722
|
+
return typeof version === "string" && version ? version : null;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* 记入(或更新)某路径的磁盘版本基线;版本为空串时不记(后端不支持版本)。
|
|
1726
|
+
* @author ddj 2026年09月15号
|
|
1727
|
+
* @param scope 工作区作用域键
|
|
1728
|
+
* @param path 文件路径
|
|
1729
|
+
* @param version 磁盘版本令牌
|
|
1730
|
+
*/
|
|
1731
|
+
function recordBaseline(scope, path, version) {
|
|
1732
|
+
if (!path || typeof version !== "string" || !version) return;
|
|
1733
|
+
let map = baselines.get(scope);
|
|
1734
|
+
if (!map) {
|
|
1735
|
+
map = /* @__PURE__ */ new Map();
|
|
1736
|
+
baselines.set(scope, map);
|
|
1737
|
+
}
|
|
1738
|
+
const key = String(path).replace(/\\/g, "/");
|
|
1739
|
+
map.delete(key);
|
|
1740
|
+
map.set(key, version);
|
|
1741
|
+
while (map.size > 128) {
|
|
1742
|
+
const oldest = map.keys().next().value;
|
|
1743
|
+
if (oldest === void 0) break;
|
|
1744
|
+
map.delete(oldest);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* 读取某路径的磁盘版本基线。
|
|
1749
|
+
* @author ddj 2026年09月15号
|
|
1750
|
+
* @param scope 工作区作用域键
|
|
1751
|
+
* @param path 文件路径
|
|
1752
|
+
* @returns 版本令牌;无基线 → null
|
|
1753
|
+
*/
|
|
1754
|
+
function baselineOf(scope, path) {
|
|
1755
|
+
return baselines.get(scope)?.get(String(path).replace(/\\/g, "/")) ?? null;
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* 清除某路径的基线(页签关闭、会话销毁时调用)。
|
|
1759
|
+
* @author ddj 2026年09月15号
|
|
1760
|
+
* @param scope 工作区作用域键
|
|
1761
|
+
* @param path 文件路径
|
|
1762
|
+
*/
|
|
1763
|
+
function clearBaseline(scope, path) {
|
|
1764
|
+
baselines.get(scope)?.delete(String(path).replace(/\\/g, "/"));
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* 记入「已读取过内容的磁盘版本」与比结果(同版本不再重复读盘)。
|
|
1768
|
+
* 读盘成功、保存成功、覆盖成功、保留本地推进基线时都要记,避免下一轮白读一次。
|
|
1769
|
+
* @author ddj 2026年09月15号
|
|
1770
|
+
* @param scope 工作区作用域键
|
|
1771
|
+
* @param path 文件路径
|
|
1772
|
+
* @param version 版本令牌(空值忽略)
|
|
1773
|
+
* @param equal 读到的磁盘内容是否与当时的缓冲内容相同
|
|
1774
|
+
*/
|
|
1775
|
+
function markReadVersion(scope, path, version, equal = false) {
|
|
1776
|
+
if (!path || typeof version !== "string" || !version) return;
|
|
1777
|
+
let map = readVersions.get(scope);
|
|
1778
|
+
if (!map) {
|
|
1779
|
+
map = /* @__PURE__ */ new Map();
|
|
1780
|
+
readVersions.set(scope, map);
|
|
1781
|
+
}
|
|
1782
|
+
const key = String(path).replace(/\\/g, "/");
|
|
1783
|
+
map.delete(key);
|
|
1784
|
+
map.set(key, {
|
|
1785
|
+
version,
|
|
1786
|
+
equal: equal === true
|
|
1787
|
+
});
|
|
1788
|
+
while (map.size > 128) {
|
|
1789
|
+
const oldest = map.keys().next().value;
|
|
1790
|
+
if (oldest === void 0) break;
|
|
1791
|
+
map.delete(oldest);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* 该磁盘版本是否已读过内容;是则同时给出当时的比对结果(轮询跳过读盘)。
|
|
1796
|
+
* 必须按版本号严格匹配:缓存里可能是更早版本的结果,版本不同一律视为未读。
|
|
1797
|
+
* @author ddj 2026年09月15号
|
|
1798
|
+
* @param scope 工作区作用域键
|
|
1799
|
+
* @param path 文件路径
|
|
1800
|
+
* @param version 当前磁盘版本令牌
|
|
1801
|
+
* @returns 未读过该版本 → null;读过 → { equal: 内容是否与缓冲一致 }
|
|
1802
|
+
*/
|
|
1803
|
+
function readVersionOf(scope, path, version) {
|
|
1804
|
+
if (!version) return null;
|
|
1805
|
+
const memo = readVersions.get(scope)?.get(String(path).replace(/\\/g, "/"));
|
|
1806
|
+
if (!memo || memo.version !== version) return null;
|
|
1807
|
+
return { equal: memo.equal };
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* 清除某路径的已读版本(重载后必须清:下一轮需按新版本重新读盘比对)。
|
|
1811
|
+
* @author ddj 2026年09月15号
|
|
1812
|
+
* @param scope 工作区作用域键
|
|
1813
|
+
* @param path 文件路径
|
|
1814
|
+
*/
|
|
1815
|
+
function clearReadVersion(scope, path) {
|
|
1816
|
+
readVersions.get(scope)?.delete(String(path).replace(/\\/g, "/"));
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* 标记某路径需要用户介入(冲突/文件消失)。
|
|
1820
|
+
* @author ddj 2026年09月15号
|
|
1821
|
+
* @param scope 工作区作用域键
|
|
1822
|
+
* @param path 文件路径
|
|
1823
|
+
* @param kind 标记类型
|
|
1824
|
+
*/
|
|
1825
|
+
function markSync(scope, path, kind) {
|
|
1826
|
+
let map = syncState.get(scope);
|
|
1827
|
+
if (!map) {
|
|
1828
|
+
map = /* @__PURE__ */ new Map();
|
|
1829
|
+
syncState.set(scope, map);
|
|
1830
|
+
}
|
|
1831
|
+
const key = String(path).replace(/\\/g, "/");
|
|
1832
|
+
map.delete(key);
|
|
1833
|
+
map.set(key, {
|
|
1834
|
+
path,
|
|
1835
|
+
kind,
|
|
1836
|
+
at: Date.now()
|
|
1837
|
+
});
|
|
1838
|
+
while (map.size > 64) {
|
|
1839
|
+
const oldest = map.keys().next().value;
|
|
1840
|
+
if (oldest === void 0) break;
|
|
1841
|
+
map.delete(oldest);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* 读取某路径的待处理同步标记。
|
|
1846
|
+
* @author ddj 2026年09月15号
|
|
1847
|
+
* @param scope 工作区作用域键
|
|
1848
|
+
* @param path 文件路径
|
|
1849
|
+
* @returns 标记;无 → null
|
|
1850
|
+
*/
|
|
1851
|
+
function readSync(scope, path) {
|
|
1852
|
+
return syncState.get(scope)?.get(String(path).replace(/\\/g, "/")) ?? null;
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* 清除某路径的同步标记(重新加载/保留本地/保存成功后调用)。
|
|
1856
|
+
* @author ddj 2026年09月15号
|
|
1857
|
+
* @param scope 工作区作用域键
|
|
1858
|
+
* @param path 文件路径
|
|
1859
|
+
*/
|
|
1860
|
+
function clearSync(scope, path) {
|
|
1861
|
+
syncState.get(scope)?.delete(String(path).replace(/\\/g, "/"));
|
|
1862
|
+
}
|
|
1863
|
+
//#endregion
|
|
1864
|
+
//#region src/client/ui/useFileWatch.ts
|
|
1865
|
+
/**
|
|
1866
|
+
* dsh-vscode-mode client — 已打开文件的外部改动轮询。
|
|
1867
|
+
*
|
|
1868
|
+
* 背景:RPC 只有客户端拉取通道(无服务端推送),编辑区因此无法感知外部写盘。
|
|
1869
|
+
* 本 hook 以轻量接口补上感知:每 POLL_MS 一次把「全部已打开页签路径」批量交给
|
|
1870
|
+
* host edrv.versions(单请求多条 stat),逐条与本地基线比对后按 watchDecision 的
|
|
1871
|
+
* 判定表把动作回调给 EditorView 执行(IO 与 UI 都在那里)。
|
|
1872
|
+
*
|
|
1873
|
+
* 为何「版本变了还要读内容」:host 版本令牌含 ctime,同字节重写(格式化工具/同步盘/
|
|
1874
|
+
* 编辑器保存策略)也会让它变化,只看版本会把无实质变化的文件反复重载(打断光标与
|
|
1875
|
+
* 滚动)。故版本变化时读一次磁盘内容与缓冲比对,相同则只推进基线;读取结果按版本
|
|
1876
|
+
* 记入 readVersions,同一版本只读一次(陈旧缓冲长期不处理也不会反复拉大文件)。
|
|
1877
|
+
*
|
|
1878
|
+
* 取舍:不使用 fs.watch(句柄/网络盘/长路径兼容性差),纯 stat+按需读轮询跨平台稳定
|
|
1879
|
+
* 且可单测;1.5s 周期对「外部改文件 → 编辑区可见」足够及时,标签页隐藏时整轮跳过。
|
|
1880
|
+
*
|
|
1881
|
+
* 上报规则(避免每轮都弹提示):
|
|
1882
|
+
* - 首次观测某路径(无标记):报 conflict/deleted,并落标记;
|
|
1883
|
+
* - 已有标记且仍异常:不再重复回调(提示已在屏上),版本恢复一致时自动清标记;
|
|
1884
|
+
* - 站点不可达(老 host 无此方法/离线):整轮静默放弃,不清标记、不放大重试。
|
|
1885
|
+
*
|
|
1886
|
+
* 作者 ddj 2026-09-15
|
|
1887
|
+
*/
|
|
1888
|
+
/** 轮询周期:外部改动可见延迟上限(标签页隐藏时跳过整轮)。 */
|
|
1889
|
+
const POLL_MS = 1500;
|
|
1890
|
+
/**
|
|
1891
|
+
* 装配外部改动轮询(挂载即开始,卸载/换会话即停)。
|
|
1892
|
+
* @author ddj 2026年09月15号
|
|
1893
|
+
* @param options 会话/作用域/ref 与回调
|
|
1894
|
+
*/
|
|
1895
|
+
function useFileWatch(options) {
|
|
1896
|
+
const { sessionId, scope, tabsRef, dirtyRef, onReadDisk, onDiskChange } = options;
|
|
1897
|
+
const cbRef = react.useRef(onDiskChange);
|
|
1898
|
+
cbRef.current = onDiskChange;
|
|
1899
|
+
const readRef = react.useRef(onReadDisk);
|
|
1900
|
+
readRef.current = onReadDisk;
|
|
1901
|
+
/** 在途守卫:上一轮未返回(host 卡住)时跳过本轮,避免请求堆积。 */
|
|
1902
|
+
const busyRef = react.useRef(false);
|
|
1903
|
+
react.useEffect(() => {
|
|
1904
|
+
if (!sessionId) return;
|
|
1905
|
+
const context = {
|
|
1906
|
+
sessionId,
|
|
1907
|
+
scope,
|
|
1908
|
+
dirtyRef,
|
|
1909
|
+
onReadDisk: (path) => readRef.current ? readRef.current(path) : Promise.resolve(null),
|
|
1910
|
+
onChange: (change) => cbRef.current(change)
|
|
1911
|
+
};
|
|
1912
|
+
/**
|
|
1913
|
+
* 一轮观测:批量取版本 → 逐条判定 → 回调。
|
|
1914
|
+
* @author ddj 2026年09月15号
|
|
1915
|
+
*/
|
|
1916
|
+
const poll = async () => {
|
|
1917
|
+
if (busyRef.current) return;
|
|
1918
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
1919
|
+
const paths = (tabsRef.current ?? []).filter((p) => typeof p === "string" && p);
|
|
1920
|
+
if (!paths.length) return;
|
|
1921
|
+
busyRef.current = true;
|
|
1922
|
+
try {
|
|
1923
|
+
const res = await rpc("edrv.versions", {
|
|
1924
|
+
sessionId,
|
|
1925
|
+
paths
|
|
1926
|
+
});
|
|
1927
|
+
if (!res || !res.ok || !Array.isArray(res.items)) return;
|
|
1928
|
+
for (const item of res.items) {
|
|
1929
|
+
if (!item || !item.path) continue;
|
|
1930
|
+
await evaluate(item, context);
|
|
1931
|
+
}
|
|
1932
|
+
} catch (error) {} finally {
|
|
1933
|
+
busyRef.current = false;
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
const timer = window.setInterval(() => {
|
|
1937
|
+
poll();
|
|
1938
|
+
}, POLL_MS);
|
|
1939
|
+
poll();
|
|
1940
|
+
return () => window.clearInterval(timer);
|
|
1941
|
+
}, [sessionId, scope]);
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* 单条判定:按 watchDecision 的判定表决定动作,必要时清/落台账标记。
|
|
1945
|
+
* 版本变化时先读磁盘内容(同版本只读一次)再定分支:内容相同只推进基线,
|
|
1946
|
+
* 内容不同且缓冲脏才升级为冲突提示。
|
|
1947
|
+
*
|
|
1948
|
+
* 导出原因:这是「观测 → 动作派发」的关键落点(三条分支各自的副作用不轻),
|
|
1949
|
+
* 用假 fetch + 假 context 可直接驱动它做端到端断言,无需渲染 React 树。
|
|
1950
|
+
* 仅供测试与同模块内 poll 调用,不属于对外 API。
|
|
1951
|
+
* @author ddj 2026年09月15号
|
|
1952
|
+
* @param item host 版本条目
|
|
1953
|
+
* @param context 轮询上下文(作用域/脏标记/读盘/回调)
|
|
1954
|
+
*/
|
|
1955
|
+
async function evaluate(item, context) {
|
|
1956
|
+
const path = item.path;
|
|
1957
|
+
const scope = context.scope;
|
|
1958
|
+
const baseline = baselineOf(scope, path);
|
|
1959
|
+
const version = versionOf(item);
|
|
1960
|
+
const missing = item.type === "missing";
|
|
1961
|
+
const dirty = context.dirtyRef.current?.[path] === true;
|
|
1962
|
+
if (!baseline) {
|
|
1963
|
+
recordBaseline(scope, path, version);
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
const versionChanged = version !== baseline;
|
|
1967
|
+
if (!versionChanged && !missing) {
|
|
1968
|
+
clearSync(scope, path);
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
const mode = await inspect(path, version, context);
|
|
1972
|
+
if (mode.known && mode.equal) {
|
|
1973
|
+
recordBaseline(scope, path, version);
|
|
1974
|
+
markReadVersion(scope, path, version, true);
|
|
1975
|
+
clearSync(scope, path);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const action = syncDecision({
|
|
1979
|
+
hasBaseline: true,
|
|
1980
|
+
versionChanged,
|
|
1981
|
+
missing,
|
|
1982
|
+
clean: !dirty,
|
|
1983
|
+
contentEqual: mode.equal
|
|
1984
|
+
});
|
|
1985
|
+
if (action === "sync-silent") {
|
|
1986
|
+
clearSync(scope, path);
|
|
1987
|
+
if (mode.known) {
|
|
1988
|
+
recordBaseline(scope, path, version);
|
|
1989
|
+
markReadVersion(scope, path, version, false);
|
|
1990
|
+
} else clearReadVersion(scope, path);
|
|
1991
|
+
context.onChange({
|
|
1992
|
+
path,
|
|
1993
|
+
kind: "modified"
|
|
1994
|
+
});
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (action === "none") return;
|
|
1998
|
+
const kind = action === "deleted" ? "deleted" : "conflict";
|
|
1999
|
+
const flagged = readSync(scope, path);
|
|
2000
|
+
if (flagged && flagged.kind === kind) return;
|
|
2001
|
+
markSync(scope, path, kind);
|
|
2002
|
+
context.onChange({
|
|
2003
|
+
path,
|
|
2004
|
+
kind
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* 读盘并与调用方缓冲比对(脏缓冲也需要:内容相同就不该报冲突)。
|
|
2009
|
+
* 同版本只读一次:已读过该版本的路径直接回放当时的比对结果(不重复拉取大文件)。
|
|
2010
|
+
* @author ddj 2026年09月15号
|
|
2011
|
+
* @param path 文件路径
|
|
2012
|
+
* @param version 当前磁盘版本(可为 null)
|
|
2013
|
+
* @param context 轮询上下文
|
|
2014
|
+
* @returns { known: 是否拿到磁盘内容, equal: 内容是否与缓冲一致 }
|
|
2015
|
+
*/
|
|
2016
|
+
async function inspect(path, version, context) {
|
|
2017
|
+
if (!version) return {
|
|
2018
|
+
known: false,
|
|
2019
|
+
equal: false
|
|
2020
|
+
};
|
|
2021
|
+
const cached = readVersionOf(context.scope, path, version);
|
|
2022
|
+
if (cached) return {
|
|
2023
|
+
known: true,
|
|
2024
|
+
equal: cached.equal
|
|
2025
|
+
};
|
|
2026
|
+
const disk = await context.onReadDisk?.(path).catch(() => null);
|
|
2027
|
+
if (!disk) return {
|
|
2028
|
+
known: false,
|
|
2029
|
+
equal: false
|
|
2030
|
+
};
|
|
2031
|
+
markReadVersion(context.scope, path, version, disk.equal);
|
|
2032
|
+
return {
|
|
2033
|
+
known: true,
|
|
2034
|
+
equal: disk.equal
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
1643
2037
|
//#endregion
|
|
1644
2038
|
//#region src/client/pdf/pdfLoader.ts
|
|
1645
2039
|
/**
|
|
@@ -5368,7 +5762,7 @@ window.__ModuleLoader__.load({
|
|
|
5368
5762
|
if (!uri) return "";
|
|
5369
5763
|
if (uri.startsWith("edrv://")) return uri;
|
|
5370
5764
|
const path = relativeLspPath(uri, root);
|
|
5371
|
-
return "edrv:///" + encodeURI(path);
|
|
5765
|
+
return "edrv:///" + encodeURI(String(path ?? "").replace(/^\/+/, ""));
|
|
5372
5766
|
}
|
|
5373
5767
|
/**
|
|
5374
5768
|
* 归一化 Location 的 uri → 工作区路径(剥离 scheme,不补盘符)。
|
|
@@ -7188,6 +7582,8 @@ window.__ModuleLoader__.load({
|
|
|
7188
7582
|
activeRef.current = active;
|
|
7189
7583
|
const tabsRef = react.default.useRef([]);
|
|
7190
7584
|
tabsRef.current = tabs;
|
|
7585
|
+
const tabPathsRef = react.default.useRef([]);
|
|
7586
|
+
tabPathsRef.current = tabs.map((t) => t.path);
|
|
7191
7587
|
const dirtyRef = react.default.useRef({});
|
|
7192
7588
|
dirtyRef.current = dirtyMap;
|
|
7193
7589
|
const tabsHostRef = react.default.useRef(null);
|
|
@@ -7206,6 +7602,9 @@ window.__ModuleLoader__.load({
|
|
|
7206
7602
|
const doSaveRef = react.default.useRef(null);
|
|
7207
7603
|
const onEditRef = react.default.useRef(null);
|
|
7208
7604
|
const saveViewStateRef = react.default.useRef(null);
|
|
7605
|
+
const revRef = react.default.useRef({});
|
|
7606
|
+
const diskChangeRef = react.default.useRef(null);
|
|
7607
|
+
const [diskFlag, setDiskFlag] = react.default.useState(null);
|
|
7209
7608
|
const diffRendererRef = react.default.useRef(null);
|
|
7210
7609
|
const layoutRef = react.default.useRef(layout);
|
|
7211
7610
|
layoutRef.current = layout;
|
|
@@ -7389,6 +7788,10 @@ window.__ModuleLoader__.load({
|
|
|
7389
7788
|
pdfCtlRef.current.delete(path);
|
|
7390
7789
|
}
|
|
7391
7790
|
pdfB64CacheRef.current.delete(path);
|
|
7791
|
+
clearBaseline(scope, path);
|
|
7792
|
+
clearReadVersion(scope, path);
|
|
7793
|
+
clearSync(scope, path);
|
|
7794
|
+
delete revRef.current[path];
|
|
7392
7795
|
};
|
|
7393
7796
|
/**
|
|
7394
7797
|
* 提交关闭结果:一次性更新页签与活动页签(补位规则见 tabActions.applyClose)。
|
|
@@ -7480,19 +7883,33 @@ window.__ModuleLoader__.load({
|
|
|
7480
7883
|
/**
|
|
7481
7884
|
* 加载文件内容(对齐 VSCode model 复用:会话内已打开的 model 直接秒显,
|
|
7482
7885
|
* 后台静默 RPC 校验防陈旧;首次打开走原读取流程)。
|
|
7483
|
-
*
|
|
7886
|
+
* 每条成功分支都记下磁盘版本基线:外部改动轮询据此判断缓冲是否已陈旧,
|
|
7887
|
+
* 保存时作为版本守卫令牌随 edrv.save 回传(读后被外部改过则拒绝写入)。
|
|
7888
|
+
* @author ddj 2026年08月28号 / 2026年09月15号
|
|
7484
7889
|
* @param path 文件路径
|
|
7485
7890
|
* @param sid 会话 id
|
|
7486
7891
|
* @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
|
|
7892
|
+
* @param version 已知磁盘版本(缺省走 RPC 返回值)
|
|
7487
7893
|
*/
|
|
7488
|
-
const loadContent = (path, sid, force) => {
|
|
7894
|
+
const loadContent = (path, sid, force, version) => {
|
|
7489
7895
|
const seq = ++loadSeqRef.current;
|
|
7896
|
+
/**
|
|
7897
|
+
* 记下磁盘版本基线;markRead=true 表示本次真的从磁盘读了内容
|
|
7898
|
+
* (已读版本台账随之失效,下一轮轮询按新版本重新比对)。
|
|
7899
|
+
*/
|
|
7900
|
+
const mark = (ver, markRead) => {
|
|
7901
|
+
if (typeof ver !== "string" || !ver) return;
|
|
7902
|
+
revRef.current[path] = ver;
|
|
7903
|
+
recordBaseline(scope, path, ver);
|
|
7904
|
+
clearSync(scope, path);
|
|
7905
|
+
if (markRead === true) clearReadVersion(scope, path);
|
|
7906
|
+
};
|
|
7490
7907
|
if (isImagePath(path) && !svgTextRef.current.has(path)) {
|
|
7491
|
-
loadImage(path, sid, seq);
|
|
7908
|
+
loadImage(path, sid, seq, version);
|
|
7492
7909
|
return;
|
|
7493
7910
|
}
|
|
7494
7911
|
if (isPdfPath(path)) {
|
|
7495
|
-
loadPdf(path, sid, seq, force === true);
|
|
7912
|
+
loadPdf(path, sid, seq, force === true, version);
|
|
7496
7913
|
return;
|
|
7497
7914
|
}
|
|
7498
7915
|
const cachedModel = force ? null : modelsRef.current.get(path);
|
|
@@ -7509,6 +7926,7 @@ window.__ModuleLoader__.load({
|
|
|
7509
7926
|
path
|
|
7510
7927
|
}).then((res) => {
|
|
7511
7928
|
if (seq !== loadSeqRef.current || path !== active) return;
|
|
7929
|
+
if (res && res.ok) mark(res.version);
|
|
7512
7930
|
if (res && res.ok && res.content !== cachedModel.getValue()) {
|
|
7513
7931
|
saveViewState(path);
|
|
7514
7932
|
setContent(res.content);
|
|
@@ -7528,6 +7946,7 @@ window.__ModuleLoader__.load({
|
|
|
7528
7946
|
}).then((res) => {
|
|
7529
7947
|
if (seq !== loadSeqRef.current || path !== active) return;
|
|
7530
7948
|
if (res && res.ok) {
|
|
7949
|
+
mark(res.version, true);
|
|
7531
7950
|
setContent(res.content);
|
|
7532
7951
|
setContentPath(path);
|
|
7533
7952
|
setLoadStage((prev) => ({
|
|
@@ -7552,12 +7971,13 @@ window.__ModuleLoader__.load({
|
|
|
7552
7971
|
};
|
|
7553
7972
|
/**
|
|
7554
7973
|
* 加载图片文件为 data URL(编辑区只读预览);失败走 loadError 面板(重试经 loadContent 分派回此)。
|
|
7555
|
-
* @author ddj 2026年09月08号
|
|
7974
|
+
* @author ddj 2026年09月08号 / 2026年09月15号
|
|
7556
7975
|
* @param path 图片文件路径
|
|
7557
7976
|
* @param sid 会话 id
|
|
7558
7977
|
* @param seq 加载序号(过期响应丢弃)
|
|
7978
|
+
* @param version 已知磁盘版本(图片同走外部改动轮询,读到新版本即重取)
|
|
7559
7979
|
*/
|
|
7560
|
-
const loadImage = (path, sid, seq) => {
|
|
7980
|
+
const loadImage = (path, sid, seq, version) => {
|
|
7561
7981
|
setImageSrc(null);
|
|
7562
7982
|
setImgSize(null);
|
|
7563
7983
|
setImgBroken(false);
|
|
@@ -7578,6 +7998,9 @@ window.__ModuleLoader__.load({
|
|
|
7578
7998
|
setStatus("读取失败");
|
|
7579
7999
|
return;
|
|
7580
8000
|
}
|
|
8001
|
+
const imgVersion = res.version ?? version;
|
|
8002
|
+
recordBaseline(scope, path, imgVersion);
|
|
8003
|
+
clearReadVersion(scope, path);
|
|
7581
8004
|
setImageSrc(dataUrlOf(res.content, res.mime));
|
|
7582
8005
|
setLoadStage({
|
|
7583
8006
|
progress: 100,
|
|
@@ -7607,8 +8030,9 @@ window.__ModuleLoader__.load({
|
|
|
7607
8030
|
* @param sid 会话 id
|
|
7608
8031
|
* @param seq 加载序号(过期响应丢弃)
|
|
7609
8032
|
* @param force true=绕过缓存强制 RPC 重读(刷新按钮)
|
|
8033
|
+
* @param version 已知磁盘版本(外部改动轮询触发时带入)
|
|
7610
8034
|
*/
|
|
7611
|
-
const loadPdf = (path, sid, seq, force) => {
|
|
8035
|
+
const loadPdf = (path, sid, seq, force, version) => {
|
|
7612
8036
|
setPdfBytes(null);
|
|
7613
8037
|
setLoadStage({
|
|
7614
8038
|
progress: monaco ? 72 : 12,
|
|
@@ -7634,6 +8058,9 @@ window.__ModuleLoader__.load({
|
|
|
7634
8058
|
}).then((res) => {
|
|
7635
8059
|
if (seq !== loadSeqRef.current || path !== active) return;
|
|
7636
8060
|
if (res && res.ok && res.encoding === "base64") {
|
|
8061
|
+
const pdfVersion = res.version ?? version;
|
|
8062
|
+
recordBaseline(scope, path, pdfVersion);
|
|
8063
|
+
clearReadVersion(scope, path);
|
|
7637
8064
|
cache.set(path, res.content);
|
|
7638
8065
|
while (cache.size > 6) cache.delete(cache.keys().next().value);
|
|
7639
8066
|
setPdfBytes(base64ToBytes(res.content));
|
|
@@ -7668,6 +8095,66 @@ window.__ModuleLoader__.load({
|
|
|
7668
8095
|
window.removeEventListener("edrv:refresh", onRefresh);
|
|
7669
8096
|
};
|
|
7670
8097
|
}, [sessionId]);
|
|
8098
|
+
/**
|
|
8099
|
+
* 展示外部同步提示(按路径+类型去重:同一文件重复回调不重置已关闭的提示)。
|
|
8100
|
+
* @author ddj 2026年09月15号
|
|
8101
|
+
* @param flag 同步标记
|
|
8102
|
+
*/
|
|
8103
|
+
const markDiskFlag = (flag) => {
|
|
8104
|
+
setDiskFlag((prev) => prev && prev.path === flag.path && prev.kind === flag.kind ? prev : flag);
|
|
8105
|
+
};
|
|
8106
|
+
/**
|
|
8107
|
+
* 外部改动落地:干净缓冲直接刷入(含差异标记重算),脏缓冲只提示不覆盖。
|
|
8108
|
+
* 经 ref 暴露给轮询 hook(hook 只负责观测,IO/UI 全在这里)。
|
|
8109
|
+
* @author ddj 2026年09月15号
|
|
8110
|
+
* @param change 轮询判定出的外部变更
|
|
8111
|
+
*/
|
|
8112
|
+
const onDiskChange = (change) => {
|
|
8113
|
+
if (!change || !change.path) return;
|
|
8114
|
+
emitFileChanged(change.path);
|
|
8115
|
+
if (change.kind === "modified") {
|
|
8116
|
+
loadContent(change.path, sessionId, true);
|
|
8117
|
+
clearSync(scope, change.path);
|
|
8118
|
+
setDiskFlag((prev) => prev && prev.path === change.path ? null : prev);
|
|
8119
|
+
setStatus("已同步外部修改 " + (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8));
|
|
8120
|
+
emitRefresh();
|
|
8121
|
+
return;
|
|
8122
|
+
}
|
|
8123
|
+
const kind = change.kind === "deleted" ? "deleted" : "conflict";
|
|
8124
|
+
markDiskFlag(readSync(scope, change.path) ?? {
|
|
8125
|
+
path: change.path,
|
|
8126
|
+
kind,
|
|
8127
|
+
at: Date.now()
|
|
8128
|
+
});
|
|
8129
|
+
setStatus(kind === "deleted" ? "文件已被外部删除" : "外部已修改(未保存的编辑保留)");
|
|
8130
|
+
};
|
|
8131
|
+
diskChangeRef.current = onDiskChange;
|
|
8132
|
+
/**
|
|
8133
|
+
* 版本变化时读磁盘并与缓冲比对:内容相同 → 只推进基线(不做无意义重载);
|
|
8134
|
+
* 内容不同 → 由判定表决定自动刷入还是冲突提示。
|
|
8135
|
+
* @author ddj 2026年09月15号
|
|
8136
|
+
* @param path 文件路径
|
|
8137
|
+
* @returns 比对结果;模型不可用/读失败 → null(按「需要重载」处理)
|
|
8138
|
+
*/
|
|
8139
|
+
const readDiskCompare = (path) => {
|
|
8140
|
+
const model = modelsRef.current?.get(path);
|
|
8141
|
+
if (!model || typeof model.getValue !== "function") return Promise.resolve(null);
|
|
8142
|
+
return rpc("edrv.read", {
|
|
8143
|
+
sessionId,
|
|
8144
|
+
path
|
|
8145
|
+
}).then((res) => {
|
|
8146
|
+
if (!res || !res.ok) return null;
|
|
8147
|
+
return { equal: res.content === model.getValue() };
|
|
8148
|
+
}).catch(() => null);
|
|
8149
|
+
};
|
|
8150
|
+
useFileWatch({
|
|
8151
|
+
sessionId,
|
|
8152
|
+
scope,
|
|
8153
|
+
tabsRef: tabPathsRef,
|
|
8154
|
+
dirtyRef,
|
|
8155
|
+
onReadDisk: readDiskCompare,
|
|
8156
|
+
onDiskChange: (change) => diskChangeRef.current?.(change)
|
|
8157
|
+
});
|
|
7671
8158
|
react.default.useEffect(() => {
|
|
7672
8159
|
const publishLsp = (servers) => setLspServers(Array.isArray(servers) ? servers : []);
|
|
7673
8160
|
const unsubscribe = onLspProgress(publishLsp);
|
|
@@ -8174,7 +8661,7 @@ window.__ModuleLoader__.load({
|
|
|
8174
8661
|
const cache = modelsRef.current;
|
|
8175
8662
|
let model = cache.get(path);
|
|
8176
8663
|
if (!model) {
|
|
8177
|
-
const uri = window.monaco.Uri.parse("edrv:///" + encodeURI(path));
|
|
8664
|
+
const uri = window.monaco.Uri.parse("edrv:///" + encodeURI(String(path ?? "").replace(/^\/+/, "")));
|
|
8178
8665
|
model = window.monaco.editor.getModel(uri) || window.monaco.editor.createModel(text ?? "", langOf(path), uri);
|
|
8179
8666
|
rememberModel(cache, path, model);
|
|
8180
8667
|
} else if (text !== void 0 && model.getValue() !== text) {
|
|
@@ -8188,13 +8675,24 @@ window.__ModuleLoader__.load({
|
|
|
8188
8675
|
* 提交待执行的防抖保存(**真正执行保存**,不是取消)。
|
|
8189
8676
|
* 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
|
|
8190
8677
|
* 旧实现把它当「立即保存」调用 → 防抖窗口内切页签/关闭文件会静默丢改动。
|
|
8191
|
-
*
|
|
8678
|
+
* 外部改动待处理(冲突/文件被删)时跳过:切页签/卸载不得用陈旧缓冲覆盖磁盘,
|
|
8679
|
+
* 缓冲内容仍在 model 里,用户处理完冲突后再保存。
|
|
8680
|
+
* @author ddj 2026年09月11号 / 2026年09月15号
|
|
8681
|
+
* @param path 目标路径(缺省 = 当前活动文件)
|
|
8682
|
+
* @returns 是否执行了保存
|
|
8192
8683
|
*/
|
|
8193
|
-
const flushSave = () => {
|
|
8684
|
+
const flushSave = (path) => {
|
|
8685
|
+
const target = path ?? active;
|
|
8686
|
+
if (target && readSync(scope, target)) return false;
|
|
8194
8687
|
saveTimerRef.current?.flush();
|
|
8688
|
+
return true;
|
|
8195
8689
|
};
|
|
8196
8690
|
const doSave = (silent) => {
|
|
8197
8691
|
if (!active) return;
|
|
8692
|
+
if (silent && readSync(scope, active)) {
|
|
8693
|
+
setStatus("外部已修改(未保存的编辑保留)");
|
|
8694
|
+
return;
|
|
8695
|
+
}
|
|
8198
8696
|
if (isPdfPath(active)) {
|
|
8199
8697
|
const ctl = pdfCtlRef.current.get(active);
|
|
8200
8698
|
if (!ctl) {
|
|
@@ -8215,30 +8713,49 @@ window.__ModuleLoader__.load({
|
|
|
8215
8713
|
return;
|
|
8216
8714
|
}
|
|
8217
8715
|
const text = ed.getValue();
|
|
8716
|
+
const path = active;
|
|
8218
8717
|
if (!silent) setStatus("保存中…");
|
|
8219
|
-
savingRef.current.add(
|
|
8718
|
+
savingRef.current.add(path);
|
|
8719
|
+
const rev = revRef.current[path];
|
|
8220
8720
|
rpc("edrv.save", {
|
|
8221
8721
|
sessionId,
|
|
8222
|
-
path
|
|
8223
|
-
content: text
|
|
8722
|
+
path,
|
|
8723
|
+
content: text,
|
|
8724
|
+
rev: typeof rev === "string" && rev ? rev : void 0
|
|
8224
8725
|
}).then((res) => {
|
|
8225
8726
|
if (res && res.ok) {
|
|
8226
|
-
|
|
8727
|
+
if (res.rev) revRef.current[path] = res.rev;
|
|
8728
|
+
recordBaseline(scope, path, res.rev);
|
|
8729
|
+
markReadVersion(scope, path, res.rev, true);
|
|
8730
|
+
clearSync(scope, path);
|
|
8227
8731
|
setContent(text);
|
|
8228
|
-
setContentPath(
|
|
8229
|
-
setDirtyMap((d) => Object.assign({}, d, { [
|
|
8732
|
+
setContentPath(path);
|
|
8733
|
+
setDirtyMap((d) => Object.assign({}, d, { [path]: false }));
|
|
8734
|
+
if (path === active) {
|
|
8735
|
+
setStatus("已保存 " + (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8));
|
|
8736
|
+
setDiskFlag((prev) => prev && prev.path === path ? null : prev);
|
|
8737
|
+
}
|
|
8230
8738
|
refreshRecords();
|
|
8231
8739
|
emitRefresh();
|
|
8232
|
-
if (/\.code-snippets$/i.test(
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8740
|
+
if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent("edrv:snippets-changed"));
|
|
8741
|
+
return;
|
|
8742
|
+
}
|
|
8743
|
+
if (res && res.conflict) {
|
|
8744
|
+
markDiskFlag({
|
|
8745
|
+
path,
|
|
8746
|
+
kind: "conflict",
|
|
8747
|
+
at: Date.now()
|
|
8748
|
+
});
|
|
8749
|
+
setStatus("保存被拒:文件已被外部修改");
|
|
8750
|
+
return;
|
|
8236
8751
|
}
|
|
8752
|
+
setStatus("保存失败");
|
|
8753
|
+
setError(res?.error ? String(res.error) : "保存失败");
|
|
8237
8754
|
}).catch((e) => {
|
|
8238
8755
|
setStatus("保存失败");
|
|
8239
8756
|
setError("保存异常:" + String(e));
|
|
8240
8757
|
}).finally(() => {
|
|
8241
|
-
savingRef.current.delete(
|
|
8758
|
+
savingRef.current.delete(path);
|
|
8242
8759
|
});
|
|
8243
8760
|
};
|
|
8244
8761
|
doSaveRef.current = doSave;
|
|
@@ -8249,12 +8766,17 @@ window.__ModuleLoader__.load({
|
|
|
8249
8766
|
* 这里再处理**仍标脏**的页签 —— 包括活动页签(其保存可能在途或失败),
|
|
8250
8767
|
* 用 model 里的当前文本补一次,保证关闭前内容一定写到磁盘。
|
|
8251
8768
|
* 保存失败只保留脏标记(不丢用户编辑),并在状态栏给出提示。
|
|
8252
|
-
*
|
|
8769
|
+
* 外部改动待处理的页签跳过(版本守卫下必被拒绝;缓冲仍在 model 里,不丢内容)。
|
|
8770
|
+
* @author ddj 2026年09月11号 / 2026年09月15号
|
|
8253
8771
|
* @param paths 即将关闭的页签路径
|
|
8254
8772
|
*/
|
|
8255
8773
|
const persistDirty = (paths) => {
|
|
8256
8774
|
for (const path of paths) {
|
|
8257
8775
|
if (!dirtyRef.current[path]) continue;
|
|
8776
|
+
if (readSync(scope, path)) {
|
|
8777
|
+
setStatus("未落盘(外部已修改):" + baseNameOf(path));
|
|
8778
|
+
continue;
|
|
8779
|
+
}
|
|
8258
8780
|
if (savingRef.current.has(path)) continue;
|
|
8259
8781
|
const pdfCtl = pdfCtlRef.current.get(path);
|
|
8260
8782
|
if (pdfCtl) {
|
|
@@ -8268,12 +8790,19 @@ window.__ModuleLoader__.load({
|
|
|
8268
8790
|
}
|
|
8269
8791
|
const content = model.getValue();
|
|
8270
8792
|
savingRef.current.add(path);
|
|
8793
|
+
const rev = revRef.current[path];
|
|
8271
8794
|
rpc("edrv.save", {
|
|
8272
8795
|
sessionId,
|
|
8273
8796
|
path,
|
|
8274
|
-
content
|
|
8797
|
+
content,
|
|
8798
|
+
rev: typeof rev === "string" && rev ? rev : void 0
|
|
8275
8799
|
}).then((res) => {
|
|
8276
|
-
if (res && res.ok)
|
|
8800
|
+
if (res && res.ok) {
|
|
8801
|
+
if (res.rev) revRef.current[path] = res.rev;
|
|
8802
|
+
recordBaseline(scope, path, res.rev);
|
|
8803
|
+
markReadVersion(scope, path, res.rev, true);
|
|
8804
|
+
setDirtyMap((d) => Object.assign({}, d, { [path]: false }));
|
|
8805
|
+
}
|
|
8277
8806
|
}).catch((e) => dbg(sessionId, "关闭前落盘失败:" + path + " · " + String(e))).finally(() => {
|
|
8278
8807
|
savingRef.current.delete(path);
|
|
8279
8808
|
});
|
|
@@ -8283,6 +8812,10 @@ window.__ModuleLoader__.load({
|
|
|
8283
8812
|
if (!editorRef.current || !active) return;
|
|
8284
8813
|
setDirtyMap((d) => Object.assign({}, d, { [active]: true }));
|
|
8285
8814
|
setStatus("编辑中…");
|
|
8815
|
+
if (readSync(scope, active)) {
|
|
8816
|
+
setStatus("外部已修改(未保存的编辑保留)");
|
|
8817
|
+
return;
|
|
8818
|
+
}
|
|
8286
8819
|
saveTimerRef.current?.arm(schedule, 700, () => doSave(true));
|
|
8287
8820
|
};
|
|
8288
8821
|
onEditRef.current = onEdit;
|
|
@@ -8634,12 +9167,80 @@ window.__ModuleLoader__.load({
|
|
|
8634
9167
|
if (sum.pendingFiles.some((file) => sameFile(file.path, active))) gotoFile(1);
|
|
8635
9168
|
else openFile(sum.pendingFiles[0].path, true);
|
|
8636
9169
|
};
|
|
9170
|
+
/**
|
|
9171
|
+
* 重新从磁盘加载当前文件(工具栏 ⟳ / 冲突提示「重新加载」/ 差异决策后刷新)。
|
|
9172
|
+
* 一律强制重读(跳过 model 复用):这才是「用户要看到磁盘真实内容」的语义。
|
|
9173
|
+
* @author ddj 2026年09月15号
|
|
9174
|
+
* @param skipStale 差异记录刷新是否跳过 stale 清理
|
|
9175
|
+
*/
|
|
8637
9176
|
const reloadFile = (skipStale) => {
|
|
8638
9177
|
if (!active) return;
|
|
8639
|
-
|
|
9178
|
+
const path = active;
|
|
9179
|
+
loadContent(path, sessionId, true);
|
|
9180
|
+
clearSync(scope, path);
|
|
9181
|
+
setDiskFlag((prev) => prev && prev.path === path ? null : prev);
|
|
8640
9182
|
refreshRecords(skipStale === true);
|
|
8641
9183
|
};
|
|
8642
9184
|
/**
|
|
9185
|
+
* 冲突处理:用编辑器内容覆盖磁盘(在缓冲内容为权威时用户显式选择)。
|
|
9186
|
+
* 覆盖不带版本令牌(host 无条件写入),成功后以返回的新版本重记基线。
|
|
9187
|
+
* @author ddj 2026年09月15号
|
|
9188
|
+
*/
|
|
9189
|
+
const overwriteDisk = () => {
|
|
9190
|
+
const path = diskFlag?.path;
|
|
9191
|
+
const model = path ? modelsRef.current.get(path) : null;
|
|
9192
|
+
if (!path || !model) {
|
|
9193
|
+
setStatus("无法覆盖:文件未打开");
|
|
9194
|
+
return;
|
|
9195
|
+
}
|
|
9196
|
+
rpc("edrv.save", {
|
|
9197
|
+
sessionId,
|
|
9198
|
+
path,
|
|
9199
|
+
content: model.getValue()
|
|
9200
|
+
}).then((res) => {
|
|
9201
|
+
if (res && res.ok) {
|
|
9202
|
+
if (res.rev) revRef.current[path] = res.rev;
|
|
9203
|
+
recordBaseline(scope, path, res.rev);
|
|
9204
|
+
markReadVersion(scope, path, res.rev, true);
|
|
9205
|
+
clearSync(scope, path);
|
|
9206
|
+
setDirtyMap((d) => Object.assign({}, d, { [path]: false }));
|
|
9207
|
+
setDiskFlag((prev) => prev && prev.path === path ? null : prev);
|
|
9208
|
+
setStatus("已覆盖磁盘");
|
|
9209
|
+
emitRefresh();
|
|
9210
|
+
return;
|
|
9211
|
+
}
|
|
9212
|
+
setStatus("覆盖失败");
|
|
9213
|
+
setError(res?.error ? String(res.error) : "覆盖失败");
|
|
9214
|
+
}).catch((e) => setError("覆盖异常:" + String(e)));
|
|
9215
|
+
};
|
|
9216
|
+
/**
|
|
9217
|
+
* 冲突处理:保留本地编辑(关掉提示),磁盘内容不取用。
|
|
9218
|
+
* 基线推进到磁盘当前版本,避免同一外部改动被反复提示;缓冲仍标脏,
|
|
9219
|
+
* 之后的手工保存会被版本守卫拒绝并再次给出选择。
|
|
9220
|
+
* @author ddj 2026年09月15号
|
|
9221
|
+
*/
|
|
9222
|
+
const keepLocal = () => {
|
|
9223
|
+
const path = diskFlag?.path;
|
|
9224
|
+
if (!path) return;
|
|
9225
|
+
rpc("edrv.versions", {
|
|
9226
|
+
sessionId,
|
|
9227
|
+
paths: [path]
|
|
9228
|
+
}).then((res) => {
|
|
9229
|
+
const item = res && res.ok && Array.isArray(res.items) ? res.items[0] : null;
|
|
9230
|
+
if (item && item.version) {
|
|
9231
|
+
recordBaseline(scope, path, item.version);
|
|
9232
|
+
markReadVersion(scope, path, item.version, false);
|
|
9233
|
+
revRef.current[path] = item.version;
|
|
9234
|
+
}
|
|
9235
|
+
clearSync(scope, path);
|
|
9236
|
+
setDiskFlag((prev) => prev && prev.path === path ? null : prev);
|
|
9237
|
+
setStatus("已保留本地编辑(磁盘内容未取用)");
|
|
9238
|
+
}).catch(() => {
|
|
9239
|
+
clearSync(scope, path);
|
|
9240
|
+
setDiskFlag(null);
|
|
9241
|
+
});
|
|
9242
|
+
};
|
|
9243
|
+
/**
|
|
8643
9244
|
* SVG 在图片预览与文本编辑间切换(按路径记忆;重载分派随集合状态自动路由)。
|
|
8644
9245
|
* @author ddj 2026年09月08号
|
|
8645
9246
|
* @param path SVG 文件路径
|
|
@@ -9208,7 +9809,20 @@ window.__ModuleLoader__.load({
|
|
|
9208
9809
|
},
|
|
9209
9810
|
title: aiState === "error" && aiNote ? aiNote : "AI 自动补全(设置页「AI 补全」可配置)"
|
|
9210
9811
|
}, react.default.createElement("span", { className: "edrv-lsp-status-dot " + (!aiEnabled ? "idle" : aiDotCls) }), react.default.createElement("span", { className: "edrv-sp-lsp" }, aiLabel)) : null;
|
|
9211
|
-
const
|
|
9812
|
+
const diskSeg = active && diskFlag && sameFile(diskFlag.path, active) ? react.default.createElement("span", {
|
|
9813
|
+
className: "edrv-status-seg",
|
|
9814
|
+
style: {
|
|
9815
|
+
display: "inline-flex",
|
|
9816
|
+
alignItems: "center",
|
|
9817
|
+
gap: "6px",
|
|
9818
|
+
minWidth: 0,
|
|
9819
|
+
flex: "0 0 auto",
|
|
9820
|
+
cursor: "pointer"
|
|
9821
|
+
},
|
|
9822
|
+
title: "磁盘内容已被外部修改:重新加载 = 取磁盘版本;保留本地 = 继续编辑(保存时会被版本守卫拒绝)",
|
|
9823
|
+
onClick: () => reloadFile()
|
|
9824
|
+
}, react.default.createElement("span", { className: "edrv-sp-lsp" }, diskFlag.kind === "deleted" ? "⚠ 外部已删除" : "⚠ 外部已修改")) : null;
|
|
9825
|
+
const statusBar = lspSeg || aiSeg || diskSeg ? react.default.createElement("div", { className: "edrv-statusbar" }, lspSeg, diskSeg, aiSeg) : null;
|
|
9212
9826
|
const navBackTarget = navRef.current.peekBack();
|
|
9213
9827
|
const navForwardTarget = navRef.current.peekForward();
|
|
9214
9828
|
const navNameOf = (entry) => entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : "";
|
|
@@ -9245,6 +9859,43 @@ window.__ModuleLoader__.load({
|
|
|
9245
9859
|
sessionId,
|
|
9246
9860
|
onOpen: (p) => openFile(p, false)
|
|
9247
9861
|
}));
|
|
9862
|
+
/**
|
|
9863
|
+
* 外部同步提示条(冲突 / 文件被删):仅在提示对象就是当前活动文件时显示。
|
|
9864
|
+
* 不复用 error 面板:内容照常可编辑,提示条只是把「磁盘已变、怎么处理」摆在眼前。
|
|
9865
|
+
* @author ddj 2026年09月15号
|
|
9866
|
+
* @returns 提示条元素或 null
|
|
9867
|
+
*/
|
|
9868
|
+
const diskBanner = () => {
|
|
9869
|
+
const flag = diskFlag && sameFile(diskFlag.path, active) ? diskFlag : null;
|
|
9870
|
+
if (!flag) return null;
|
|
9871
|
+
const deleted = flag.kind === "deleted";
|
|
9872
|
+
const text = deleted ? "该文件已被外部删除(缓冲内容保留,保存会失败)" : "磁盘内容已被外部修改(当前有未保存编辑,未覆盖)";
|
|
9873
|
+
return react.default.createElement("div", {
|
|
9874
|
+
className: "edrv-diskbar",
|
|
9875
|
+
style: {
|
|
9876
|
+
display: "flex",
|
|
9877
|
+
alignItems: "center",
|
|
9878
|
+
gap: "8px",
|
|
9879
|
+
flexShrink: 0,
|
|
9880
|
+
padding: "4px 8px",
|
|
9881
|
+
fontSize: "12px",
|
|
9882
|
+
background: "var(--dsw-alias-bg-layer-1, rgba(255,193,7,.12))",
|
|
9883
|
+
borderBottom: "1px solid var(--dsw-alias-border-l1, rgba(255,193,7,.35))"
|
|
9884
|
+
}
|
|
9885
|
+
}, react.default.createElement("span", { title: flag.path }, "⚠ " + text), react.default.createElement("span", { style: { flex: 1 } }), react.default.createElement("button", {
|
|
9886
|
+
className: "edrv-pill edrv-pill-keep",
|
|
9887
|
+
title: "放弃缓冲内容,重新加载磁盘版本",
|
|
9888
|
+
onClick: () => reloadFile()
|
|
9889
|
+
}, "重新加载"), deleted ? null : react.default.createElement("button", {
|
|
9890
|
+
className: "edrv-pill edrv-pill-undo",
|
|
9891
|
+
title: "用编辑器内容覆盖磁盘(放弃磁盘上的外部修改)",
|
|
9892
|
+
onClick: overwriteDisk
|
|
9893
|
+
}, "覆盖磁盘"), deleted ? null : react.default.createElement("button", {
|
|
9894
|
+
className: "edrv-pill edrv-pill-ghost",
|
|
9895
|
+
title: "保留编辑器内容,不取用磁盘版本",
|
|
9896
|
+
onClick: keepLocal
|
|
9897
|
+
}, "保留本地"));
|
|
9898
|
+
};
|
|
9248
9899
|
const otherFiles = sum.pendingFiles.filter((f) => f.path !== active);
|
|
9249
9900
|
/**
|
|
9250
9901
|
* 渲染编辑器/文件加载进度面板。
|
|
@@ -9472,7 +10123,7 @@ window.__ModuleLoader__.load({
|
|
|
9472
10123
|
flexDirection: "column",
|
|
9473
10124
|
overflow: "hidden"
|
|
9474
10125
|
}
|
|
9475
|
-
}, pathBar, tabRow, sideHintEl, editorArea, statusBar);
|
|
10126
|
+
}, pathBar, tabRow, sideHintEl, diskBanner(), editorArea, statusBar);
|
|
9476
10127
|
const editorRow = react.default.createElement("div", { className: "edrv-editor-row" }, sidebarPanels ? react.default.createElement(SidebarView, {
|
|
9477
10128
|
registry: sidebarPanels,
|
|
9478
10129
|
ctx: sidebarCtx,
|
|
@@ -10269,14 +10920,14 @@ window.__ModuleLoader__.load({
|
|
|
10269
10920
|
*/
|
|
10270
10921
|
function markEditorMounted() {
|
|
10271
10922
|
mountEpoch += 1;
|
|
10272
|
-
markEditorActive
|
|
10923
|
+
markEditorActive(true);
|
|
10273
10924
|
}
|
|
10274
10925
|
/**
|
|
10275
10926
|
* 直接设置激活标记(正文卸载延迟判定用;测试 also 用作状态编排入口)。
|
|
10276
10927
|
* @author ddj 2026年09月09号
|
|
10277
10928
|
* @param active 是否激活
|
|
10278
10929
|
*/
|
|
10279
|
-
function markEditorActive
|
|
10930
|
+
function markEditorActive(active) {
|
|
10280
10931
|
editorTabActive = active;
|
|
10281
10932
|
}
|
|
10282
10933
|
/**
|
|
@@ -13576,6 +14227,8 @@ window.__ModuleLoader__.load({
|
|
|
13576
14227
|
const REVEAL_HIGHLIGHT_MS = 2e3;
|
|
13577
14228
|
const REVEAL_RETRY_MAX = 6;
|
|
13578
14229
|
const REVEAL_RETRY_MS = 120;
|
|
14230
|
+
/** 外部文件变化重列去抖:一轮外部批量写入(如 agent 连写多文件)合并为一次重列。 */
|
|
14231
|
+
const FILE_CHANGE_DEBOUNCE_MS = 400;
|
|
13579
14232
|
/**
|
|
13580
14233
|
* 目录图标元素(官方 IconFolderOpen16/Close16)。
|
|
13581
14234
|
* @author ddj 2026年09月10号
|
|
@@ -13669,6 +14322,11 @@ window.__ModuleLoader__.load({
|
|
|
13669
14322
|
const revealTryRef = react.default.useRef(0);
|
|
13670
14323
|
const revealInTreeRef = react.default.useRef(null);
|
|
13671
14324
|
const treeRef = react.default.useRef(null);
|
|
14325
|
+
const reloadDirRef = react.default.useRef(null);
|
|
14326
|
+
const changedTimerRef = react.default.useRef(null);
|
|
14327
|
+
const changedRelRef = react.default.useRef(/* @__PURE__ */ new Set());
|
|
14328
|
+
const dirsMapRef = react.default.useRef(null);
|
|
14329
|
+
dirsMapRef.current = loadDir;
|
|
13672
14330
|
/** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
|
|
13673
14331
|
const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(scope, rel) ?? null;
|
|
13674
14332
|
/** 预取子目录:≤4 个、排除重型目录、缓存新鲜跳过、已加载跳过、不级联。 */
|
|
@@ -13794,6 +14452,45 @@ window.__ModuleLoader__.load({
|
|
|
13794
14452
|
});
|
|
13795
14453
|
};
|
|
13796
14454
|
refreshRef.current = refresh;
|
|
14455
|
+
reloadDirRef.current = loadDir;
|
|
14456
|
+
/**
|
|
14457
|
+
* 磁盘文件变化(外部写入/删除/改名):把变化路径的父目录并入待重列集合,
|
|
14458
|
+
* 去抖合并后对「已展开且存在」的目录强制重列(force 跳过 host 索引命中)。
|
|
14459
|
+
* host 侧 fileVersions 已顺手失效目录树缓存,这里补上「已渲染行」的即时刷新。
|
|
14460
|
+
* @author ddj 2026年09月15号
|
|
14461
|
+
* @param path 发生变化的文件路径(工作区相对;绝对路径按最长已展开祖先匹配)
|
|
14462
|
+
*/
|
|
14463
|
+
const onFileChanged = (path) => {
|
|
14464
|
+
if (typeof path !== "string" || !path) return;
|
|
14465
|
+
const rel = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
14466
|
+
const parts = rel.split("/").filter(Boolean);
|
|
14467
|
+
if (parts.length > 1) changedRelRef.current.add(parts.slice(0, -1).join("/"));
|
|
14468
|
+
for (const dir of ancestorDirsOf(rel)) if (expandedRef.current[dir] === true) changedRelRef.current.add(dir);
|
|
14469
|
+
if (changedTimerRef.current) return;
|
|
14470
|
+
changedTimerRef.current = window.setTimeout(() => {
|
|
14471
|
+
changedTimerRef.current = null;
|
|
14472
|
+
const targets = [...changedRelRef.current];
|
|
14473
|
+
changedRelRef.current.clear();
|
|
14474
|
+
for (const dir of targets) {
|
|
14475
|
+
if (dir !== "" && expandedRef.current[dir] !== true) continue;
|
|
14476
|
+
reloadDirRef.current?.(dir, {
|
|
14477
|
+
force: true,
|
|
14478
|
+
prefetch: false
|
|
14479
|
+
});
|
|
14480
|
+
}
|
|
14481
|
+
}, FILE_CHANGE_DEBOUNCE_MS);
|
|
14482
|
+
};
|
|
14483
|
+
react.default.useEffect(() => {
|
|
14484
|
+
const handler = (event) => onFileChanged(event?.detail?.path);
|
|
14485
|
+
window.addEventListener("edrv:file-changed", handler);
|
|
14486
|
+
return () => {
|
|
14487
|
+
window.removeEventListener("edrv:file-changed", handler);
|
|
14488
|
+
if (changedTimerRef.current) {
|
|
14489
|
+
clearTimeout(changedTimerRef.current);
|
|
14490
|
+
changedTimerRef.current = null;
|
|
14491
|
+
}
|
|
14492
|
+
};
|
|
14493
|
+
}, []);
|
|
13797
14494
|
react.default.useEffect(() => {
|
|
13798
14495
|
tokensRef.current = {};
|
|
13799
14496
|
setDirs({});
|