@aiti-ai/aiti-folder 0.0.0-stage → 0.1.9

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.
@@ -0,0 +1,646 @@
1
+ /**
2
+ * aiti-folder · 文件夹 dock faces —— 左栏文件区块(sidebar 统一 dock 方案 §3.5 + v2 多实例升级)。
3
+ *
4
+ * v2(2026-09-15):文件夹区块从「编排器 props.blocks 常驻」升级为「本扩展动态注册的
5
+ * dockable slot faces」——同一 itemKey 三面:
6
+ * · sidebarExplorerDock 栈 face(FolderDockBlock,gate = sidebar 族任意落位)
7
+ * · dialogs 浮窗 face(FolderFloatFace,gate = floating;data-ext-float 交 aiti-float 接管)
8
+ * · rightFileDock 右栏 face(FolderDockRightFace,gate = docked:right-panel)
9
+ * itemKey 统一 = `aiti-folder:folder:<path>`(= 注册复合 id,dock 链路约定)——⋮ 菜单与
10
+ * 全站 dockable 面板统一(浮窗/左栏·区块/右栏·面板/上移/下移/关闭)+ 本扩展专属「钉住」。
11
+ *
12
+ * 生命周期:项目切换 → 编排器调 syncFolderForCwd(cwd) 自动开新文件夹实例(已在清单则
13
+ * 尊重现状);未钉实例保留最多 3 个(FIFO 自动关闭挤出;钉住豁免配额)。清单单一事实源
14
+ * = localStorage(跨 bundle 收敛——本扩展与 session-manager 经构建期内联各持一份模块,
15
+ * localStorage + 事件保证两份收敛)+ window 事件广播(事件驱动零轮询)。
16
+ */
17
+ import { useState, useEffect } from "react";
18
+ import { openFile, openFolder, getFeatures, apiUrl, t } from "./host-shim";
19
+ import { lastSeg } from "./lib";
20
+ import { FolderIcon, CaretIcon, MiSearch, MiRefresh, CheckIcon, PlusIcon, TerminalIcon, ChangesIcon, MiEye } from "./icons";
21
+ import { WorkspaceDropdown } from "./folder-switcher";
22
+ import {
23
+ DockBody, closePanel, present, useGate, useGateSidebar,
24
+ sidebarZoneOf, seedBlockCollapsedDefault, seedDockDefault, declareDockZones,
25
+ declareDockWindowOpener, dockIconEl,
26
+ SIDEBAR_COLLAPSE_PREFIX, clearDockPref,
27
+ useDockHeaderStateOf, requestDockCommand,
28
+ } from "../../../_shared/ext-float-dock";
29
+ import { openViewerWindow } from "../../../_shared/open-viewer-window";
30
+ import type { DockZone, DockHeaderAction } from "../../../_shared/ext-float-dock";
31
+
32
+ /* ── 多实例清单(v2 条目带钉标;旧格式 string[] 兼容读)── */
33
+ const INSTANCES_KEY = "pi:sidebar-dock:folders";
34
+ export const INSTANCES_EVENT = "pi:sidebar-dock:folders-changed";
35
+ /** 未钉实例保留上限缺省值(FIFO:超限挤掉最旧未钉并关其面板;钉住豁免)。
36
+ * 实际值读 session-manager feature `behavior.folder-quota`(extman 可改,默认 3)。
37
+ * ⚠ 配额强制只发生在 session-manager bundle 内联副本(+ 选择器/项目切换都在那边)——
38
+ * feature 也声明在 session-manager(getFeatures 只读声明方自己的 feature)。
39
+ * aiti-folder 自身副本读到空 → 回落缺省,无害。 */
40
+ const DEFAULT_MAX_UNPINNED = 3;
41
+
42
+ function maxUnpinnedFolders(): number {
43
+ let raw: unknown;
44
+ try { raw = getFeatures()["behavior.folder-quota"]; } catch { /* ignore */ }
45
+ const n = parseInt(String(raw ?? ""), 10);
46
+ if (!Number.isFinite(n)) return DEFAULT_MAX_UNPINNED;
47
+ return Math.min(9, Math.max(1, n)); // 夹取 1..9;非法(含空串/0/非数字)回落 3
48
+ }
49
+
50
+ /** 清单条目(pin=1 已钉)。 */
51
+ export interface FolderEntry { p: string; pin?: 1 }
52
+
53
+ /* ── 显示忽略项(单 folder 粒度,与钉住同构:root 路径 → 开关;跨 bundle 收敛同款)──
54
+ * localStorage `pi:folder:show-ignored` = { [rootPath]: 1 },每个 folder 面板独立;
55
+ * 开启后目录列表请求带 hidden=1(宿主上游参数名;置灰条目前端按 hidden==="ignored" 识别)。 */
56
+ const SHOW_IGNORED_KEY = "pi:folder:show-ignored";
57
+ export const SHOW_IGNORED_EVENT = "pi:folder:show-ignored-changed";
58
+
59
+ function readShowIgnoredMap(): Record<string, unknown> {
60
+ try {
61
+ const raw = JSON.parse(localStorage.getItem(SHOW_IGNORED_KEY) ?? "{}");
62
+ return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
63
+ } catch { return {}; }
64
+ }
65
+ /** 某 folder(root 路径)是否开启「显示忽略项」 */
66
+ export function isShowIgnored(path: string): boolean {
67
+ return !!readShowIgnoredMap()[path];
68
+ }
69
+ /** 翻转并返回新状态;localStorage + 事件广播(跨 bundle/跨 tab 收敛) */
70
+ export function toggleShowIgnored(path: string): boolean {
71
+ const map = readShowIgnoredMap();
72
+ const on = !map[path];
73
+ const next = { ...map };
74
+ if (on) next[path] = 1; else delete next[path];
75
+ try { localStorage.setItem(SHOW_IGNORED_KEY, JSON.stringify(next)); } catch { /* ignore */ }
76
+ try { window.dispatchEvent(new Event(SHOW_IGNORED_EVENT)); } catch { /* ignore */ }
77
+ return on;
78
+ }
79
+ /** 显示忽略项状态 hook(⋮ 菜单 active 态;toggle 经事件回灌刷新,同钉住 INSTANCES_EVENT 机制) */
80
+ export function useShowIgnored(path: string): boolean {
81
+ const [on, setOn] = useState<boolean>(() => isShowIgnored(path));
82
+ useEffect(() => {
83
+ const sync = () => setOn(isShowIgnored(path));
84
+ window.addEventListener(SHOW_IGNORED_EVENT, sync);
85
+ window.addEventListener("storage", sync);
86
+ return () => { window.removeEventListener(SHOW_IGNORED_EVENT, sync); window.removeEventListener("storage", sync); };
87
+ }, [path]);
88
+ return on;
89
+ }
90
+
91
+ function normalizeEntries(raw: unknown): FolderEntry[] {
92
+ if (!Array.isArray(raw)) return [];
93
+ const out: FolderEntry[] = [];
94
+ for (const x of raw) {
95
+ if (typeof x === "string") out.push({ p: x });
96
+ else if (x && typeof x === "object" && typeof (x as Record<string, unknown>).p === "string") {
97
+ const e = x as FolderEntry;
98
+ out.push(e.pin ? { p: e.p, pin: 1 } : { p: e.p });
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ export function readFolderEntries(): FolderEntry[] {
105
+ try { return normalizeEntries(JSON.parse(localStorage.getItem(INSTANCES_KEY) ?? "[]")); }
106
+ catch { return []; }
107
+ }
108
+ function writeFolderEntries(entries: FolderEntry[]): void {
109
+ try { localStorage.setItem(INSTANCES_KEY, JSON.stringify(entries)); } catch { /* ignore */ }
110
+ try { window.dispatchEvent(new Event(INSTANCES_EVENT)); } catch { /* ignore */ }
111
+ }
112
+ /** 纯路径清单(遍历/展示用;钉标走 readFolderPins/isFolderPinned)。 */
113
+ export function readFolderInstances(): string[] { return readFolderEntries().map((e) => e.p); }
114
+ export function readFolderPins(): Set<string> {
115
+ return new Set(readFolderEntries().filter((e) => e.pin).map((e) => e.p));
116
+ }
117
+
118
+ /** 区块 itemKey(faces 注册复合 id = itemKey,dock 方案链路约定;尾斜杠归一)。 */
119
+ export function folderItemKey(path: string): string {
120
+ return `aiti-folder:folder:${path.replace(/[\\/]+$/, "")}`;
121
+ }
122
+
123
+ /** 文件夹「新窗口打开」(独立 viewer 窗;与 folder-browser 的目录菜单同链路)。 */
124
+ function openFolderWindow(p: string, cwd?: string): void {
125
+ openViewerWindow(apiUrl, { path: p, kind: "folder", cwd });
126
+ }
127
+
128
+ /** 声明单实例可停靠位/默认位 + 播种默认折叠(幂等;添加与注册两侧都调)。
129
+ * 文件夹默认折叠 = 政策延续(用户手动展开/折叠后永不覆盖)。
130
+ * 同时声明「新窗口打开」能力(B 图标;据 2026-09-15 拍板仅文件/文件夹支持)。 */
131
+ export function declareFolderInstance(path: string): void {
132
+ const key = folderItemKey(path);
133
+ const zones: DockZone[] = [sidebarZoneOf(key), "right-panel"];
134
+ seedBlockCollapsedDefault(key, true);
135
+ seedDockDefault(key, sidebarZoneOf(key)); // 首次 open 直达左栏区块位(用户偏好优先)
136
+ declareDockZones(key, zones, sidebarZoneOf(key));
137
+ declareDockWindowOpener(key, () => openFolderWindow(path));
138
+ }
139
+
140
+ /** 添加实例(同路径去重)+ 未钉 FIFO 配额(>3 挤最旧未钉:移出清单 + 关其面板)。 */
141
+ export function addFolderInstance(path: string): void {
142
+ const p = (path || "").trim();
143
+ if (!p) return;
144
+ declareFolderInstance(p);
145
+ const entries = readFolderEntries();
146
+ if (entries.some((e) => e.p === p)) return;
147
+ let next: FolderEntry[] = [...entries, { p }];
148
+ const unpinned = next.filter((e) => !e.pin);
149
+ const victims = unpinned.slice(0, Math.max(0, unpinned.length - maxUnpinnedFolders()));
150
+ if (victims.length > 0) {
151
+ const victimSet = new Set(victims.map((e) => e.p));
152
+ next = next.filter((e) => !victimSet.has(e.p));
153
+ for (const v of victims) {
154
+ try { present(folderItemKey(v.p), "close"); } catch { /* ignore */ }
155
+ declareDockWindowOpener(folderItemKey(v.p), null); // FIFO 挤出同步注销新窗口能力
156
+ }
157
+ }
158
+ writeFolderEntries(next);
159
+ }
160
+
161
+ /** 钉住切换(钉住 = 豁免 FIFO 自动关闭配额;跨刷新持久)。
162
+ * 清单缺席(旧式 folder tab / 「打开上级目录」开的临时 tab 等非 dock 实例)→ 追加钉住条目:
163
+ * 钉住语义 = 「保留为区块」,不能静默 no-op(否则 tab 菜单「钉住」看似无效,2026-09-18)。 */
164
+ export function toggleFolderPin(path: string): void {
165
+ const p = (path || "").trim();
166
+ if (!p) return;
167
+ const entries = readFolderEntries();
168
+ if (!entries.some((e) => e.p === p)) {
169
+ writeFolderEntries([...entries, { p, pin: 1 }]);
170
+ return;
171
+ }
172
+ writeFolderEntries(entries.map((e) => (e.p === p ? (e.pin ? { p } : { p, pin: 1 }) : e)));
173
+ }
174
+ export function isFolderPinned(path: string): boolean {
175
+ return readFolderPins().has((path || "").trim());
176
+ }
177
+
178
+ /** 项目切换联动:当前 cwd 的文件夹区块**必定打开**(用户拍板 2026-09-15):
179
+ * 不管之前是否被拆出(浮窗/右栏)或关闭过——不在清单则加(含配额),然后无条件
180
+ * present(open) 带到面前(落位尊重用户偏好:浮窗偏好回浮窗、右栏偏好激活右栏 tab,
181
+ * 均可见;栈位幂等无闪烁)。关闭过的切回来也重开,用户不用找「去哪了」。
182
+ * auto 标记 = 非用户打开(项目联动/刷新恢复):小屏侧栏 overlay 不被拉起。 */
183
+ export function syncFolderForCwd(cwd: string | null | undefined): void {
184
+ openFolderInstance(cwd, { auto: true });
185
+ }
186
+
187
+ /** + 选择器/添加入口:不存在则加(含配额),已存在则按用户偏好重新带到面前。 */
188
+ export function openFolderInstance(path: string, opts?: { auto?: boolean }): void {
189
+ const p = (path || "").trim();
190
+ if (!p) return;
191
+ addFolderInstance(p);
192
+ try { present(folderItemKey(p), "open", opts); } catch { /* ignore */ }
193
+ }
194
+
195
+ /** 显式落位打开(pathPicker 顶栏选择器用):加入清单 + 直接 dock 到指定区。
196
+ * dock tab 走宿主 TabBar 渲染——⇱ 位置菜单(浮窗/左栏/右栏/上移/下移)/✕/双击拆窗
197
+ * 全套齐全;与旧式宿主 openFolder 文件夹 tab(无 dock 菜单)的本质区别在此。
198
+ * auto = 恢复性落位(宿主 select:false 后台打开):宿主据此不拉小屏右栏、不递增 reveal。 */
199
+ export function openFolderDocked(path: string, zone: DockZone = "right-panel", opts?: { auto?: boolean }): void {
200
+ const p = (path || "").trim();
201
+ if (!p) return;
202
+ addFolderInstance(p);
203
+ try { present(folderItemKey(p), `dock:${zone}`, opts); } catch { /* ignore */ }
204
+ }
205
+
206
+ /** 移除实例(⋮「关闭」= 关面板 + 移出清单——与 FIFO 自动关闭同款对外语义)。 */
207
+ export function removeFolderInstance(path: string): void {
208
+ const p = (path || "").trim();
209
+ if (!p) return;
210
+ writeFolderEntries(readFolderEntries().filter((e) => e.p !== p));
211
+ try { present(folderItemKey(p), "close"); } catch { /* ignore */ }
212
+ declareDockWindowOpener(folderItemKey(p), null); // 注销「新窗口打开」能力
213
+ }
214
+
215
+ /** 实例清单 hook(localStorage + 变更事件/跨 tab 订阅)。 */
216
+ export function useFolderInstances(): string[] {
217
+ const [list, setList] = useState<string[]>(() => readFolderInstances());
218
+ useEffect(() => {
219
+ const sync = () => setList(readFolderInstances());
220
+ sync();
221
+ window.addEventListener(INSTANCES_EVENT, sync);
222
+ window.addEventListener("storage", sync);
223
+ return () => { window.removeEventListener(INSTANCES_EVENT, sync); window.removeEventListener("storage", sync); };
224
+ }, []);
225
+ return list;
226
+ }
227
+
228
+ /** 钉标 hook(⋮ 菜单 active 态;toggle 经事件回灌)。 */
229
+ function useFolderPinned(path: string): boolean {
230
+ const [pinned, setPinned] = useState<boolean>(() => isFolderPinned(path));
231
+ useEffect(() => {
232
+ const sync = () => setPinned(isFolderPinned(path));
233
+ sync();
234
+ window.addEventListener(INSTANCES_EVENT, sync);
235
+ window.addEventListener("storage", sync);
236
+ return () => { window.removeEventListener(INSTANCES_EVENT, sync); window.removeEventListener("storage", sync); };
237
+ }, [path]);
238
+ return pinned;
239
+ }
240
+
241
+ /* ── 关闭即清形态偏好(用户拍板 2026-09-15):文件夹面板任何路径关闭(浮窗 ✕ /
242
+ * TabBar ✕ / ⋮ 关闭 / FIFO 挤出)→ 清 dockpref,下次 open/刷新自动入栈回落默认位——
243
+ * 避免「拉成浮窗关掉,刷新又弹出」;要浮窗再点一次 ⋮ 浮窗即可。
244
+ * 停靠右栏且一直开着的偏好照旧跨刷新保留(没关就不清)。
245
+ * CHANGE_EVENT 同名同义(float 状态机与 _shared 降级层都发;双 bundle 各装一份监听,
246
+ * 删同一 key 幂等无害)。 */
247
+ if (typeof window !== "undefined") {
248
+ try {
249
+ window.addEventListener("piwe-float-present-change", ((e: CustomEvent<{ key?: string; state?: string }>) => {
250
+ const d = (e as CustomEvent<{ key?: string; state?: string }>).detail;
251
+ if (!d || d.state !== "closed" || typeof d.key !== "string" || !d.key.startsWith("aiti-folder:folder:")) return;
252
+ clearDockPref(d.key);
253
+ }) as EventListener);
254
+ } catch { /* ignore */ }
255
+ }
256
+
257
+ /** 旧 blockKey(folder:<path>,v1 props.blocks 时代)折叠记忆一次性迁移到 v2 itemKey
258
+ * (模块级 once 守卫;旧 key 保留不删——回滚旧宿主仍可读)。 */
259
+ let legacyFolderMigrated = false;
260
+ function migrateFolderCollapseKeys(): void {
261
+ if (legacyFolderMigrated) return;
262
+ legacyFolderMigrated = true;
263
+ for (const p of readFolderInstances()) {
264
+ const oldKey = `folder:${p.replace(/[\\/]+$/, "")}`;
265
+ const newKey = folderItemKey(p);
266
+ try {
267
+ const old = localStorage.getItem(SIDEBAR_COLLAPSE_PREFIX + oldKey);
268
+ if (old !== null && localStorage.getItem(SIDEBAR_COLLAPSE_PREFIX + newKey) === null) {
269
+ localStorage.setItem(SIDEBAR_COLLAPSE_PREFIX + newKey, old);
270
+ }
271
+ } catch { /* ignore */ }
272
+ }
273
+ }
274
+
275
+ /* ── FolderBrowser 懒加载(~113K 大头按需 import;同 entry.tsx D3 拆包范式——
276
+ * 必须以组件渲染,严禁普通函数调用把 FolderBrowser 的 ~50 hooks 并进本壳) ── */
277
+ let FolderBrowserComp: ((props: any) => unknown) | null = null;
278
+ function FolderBrowserLazy(props: Record<string, unknown>) {
279
+ const [, force] = useState(0);
280
+ useEffect(() => {
281
+ let alive = true;
282
+ import("./folder-browser").then((m) => {
283
+ if (!alive) return;
284
+ FolderBrowserComp = m.FolderBrowser;
285
+ force((n) => n + 1);
286
+ });
287
+ return () => { alive = false; };
288
+ }, []);
289
+ const Comp = FolderBrowserComp;
290
+ return Comp ? <Comp {...(props as any)} /> : null;
291
+ }
292
+
293
+ /** 浏览器内容(三面共用):窄容器 dense 适配(工具栏横向可滚)。
294
+ * dockItemKey 仅左栏区块 face 传(v3 单行化:工具条整行移除、按钮上提 dock 头)。 */
295
+ function FolderBrowserPane(props: {
296
+ path: string;
297
+ cwd?: string;
298
+ onOpenFile?: (path: string) => void;
299
+ onOpenFolder?: (path: string) => void;
300
+ onAtMention?: (relativePath: string, isDir: boolean) => void;
301
+ gitRefreshKey?: number;
302
+ dockItemKey?: string;
303
+ /** 本 folder 的「显示忽略项」开关(单 folder 粒度;开启后列表带忽略条目置灰显示) */
304
+ showIgnored?: boolean;
305
+ }) {
306
+ const path = props.path;
307
+ return (
308
+ <div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}>
309
+ <FolderBrowserLazy
310
+ dense
311
+ filePath={path}
312
+ cwd={props.cwd || path}
313
+ onOpenFile={props.onOpenFile || ((p: string) => openFile(p))}
314
+ onOpenFolder={props.onOpenFolder || ((p: string) => openFolder(p))}
315
+ onAtMention={props.onAtMention}
316
+ gitRefreshKey={props.gitRefreshKey}
317
+ dockItemKey={props.dockItemKey}
318
+ showIgnored={props.showIgnored === true}
319
+ />
320
+ </div>
321
+ );
322
+ }
323
+
324
+ /** dock 头标题位 = 目录切换器(v3 §2.8):显示当前目录名,点击弹 workspace 列表 /
325
+ * 自定义路径 / 上一级;选择经 requestDockCommand 回内容执行(setRoot / parent)。 */
326
+ function FolderTitleSwitcher(props: { path: string; hasParent: boolean; onPick: (p: string) => void; onParent: () => void }) {
327
+ const [open, setOpen] = useState(false);
328
+ const [el, setEl] = useState<HTMLElement | null>(null);
329
+ useEffect(() => {
330
+ if (!open) return;
331
+ const onDown = (e: any) => { if (el && !el.contains(e.target)) setOpen(false); };
332
+ document.addEventListener("mousedown", onDown);
333
+ return () => document.removeEventListener("mousedown", onDown);
334
+ }, [open, el]);
335
+ const norm = props.path.replace(/[\\/]+$/, "");
336
+ const name = lastSeg(norm) || norm || "…";
337
+ return (
338
+ <span ref={setEl} style={{ position: "relative", display: "inline-flex", minWidth: 0, flex: "0 1 auto" }}>
339
+ <button
340
+ type="button" onClick={() => setOpen((v) => !v)} title={props.path}
341
+ aria-label={"切换目录:" + props.path} aria-expanded={open ? "true" : "false"}
342
+ style={{ display: "inline-flex", alignItems: "center", gap: 5, minWidth: 0, maxWidth: 170, padding: "2px 4px 2px 0", background: "none", border: "none", borderRadius: 5, color: "var(--text, #222)", cursor: "pointer", fontSize: 13, fontWeight: 500, fontFamily: "inherit" }}
343
+ >
344
+ <FolderIcon open={true} size={13} />
345
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: 1 }}>{name}</span>
346
+ <CaretIcon />
347
+ </button>
348
+ {open ? (
349
+ <WorkspaceDropdown
350
+ selected={props.path}
351
+ onSelect={(p) => { setOpen(false); if (p) props.onPick(p); }}
352
+ onClose={() => setOpen(false)}
353
+ extraTopItem={props.hasParent ? { label: "↑ " + t("files.parentDir"), onClick: () => { setOpen(false); props.onParent(); } } : undefined}
354
+ />
355
+ ) : null}
356
+ </span>
357
+ );
358
+ }
359
+
360
+ /** face 按钮事件 → 头行按钮矩形(dock 模式 plus 弹层锚点)。 */
361
+ function anchorRectOf(ev: unknown): { left: number; right: number; top: number; bottom: number } | undefined {
362
+ try {
363
+ const el = (ev as any)?.currentTarget as HTMLElement | undefined;
364
+ if (!el || typeof el.getBoundingClientRect !== "function") return undefined;
365
+ const r = el.getBoundingClientRect();
366
+ return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
367
+ } catch { return undefined; }
368
+ }
369
+
370
+ /** 扩展 ctx(syncFolderDockFaces 注册时存)——face 经 ctx.getExtService 读宿主 "pi-web.ui"
371
+ * 客户端服务(AppShell §7-4 hostUiRef:与 sidebarContent extra 同集,ref 委托最新渲染闭包)。 */
372
+ let folderExtCtx: Record<string, unknown> | null = null;
373
+
374
+ /** 三面 face 共用的宿主回调解析链(2026-09-16):
375
+ * 1) 插槽 rp(显式契约——session-manager slotExtra 全套透传,左栏栈路径);
376
+ * 2) "pi-web.ui" 服务兜底(onAtMention/onOpenFile/onOpenFolder/explorerRefreshKey/
377
+ * selectedCwd——右栏 dock tab(宿主 TabBody)与浮窗(dialogs slot)的渲染点不传 extra,
378
+ * 此前 rp.onAtMention 恒 undefined → 右栏/浮窗形态 @ 能力整组静默消失;服务化后
379
+ * 宿主无需逐插槽喂回调)。
380
+ * onOpenFile/onOpenFolder 消费前兜底 lastSeg fileName(缺省致宿主
381
+ * FileIcons.getFileIcon(undefined) 白屏,0ea18e37 教训;与 session-manager slotExtra 同口径)。 */
382
+ interface HostFaceCbs {
383
+ cwd?: string;
384
+ gitRefreshKey?: number;
385
+ onOpenFile: (p: string) => void;
386
+ onOpenFolder: (p: string, name?: string) => void;
387
+ onAtMention?: (relativePath: string, isDir: boolean) => void;
388
+ }
389
+ function resolveHostFace(rp: Record<string, unknown> | null | undefined): HostFaceCbs {
390
+ let hostUi: { get?: (name: string) => unknown; } | undefined;
391
+ try {
392
+ const getSvc = folderExtCtx?.getExtService as ((k: string) => unknown) | undefined;
393
+ hostUi = getSvc ? getSvc("pi-web.ui") as { get?: (name: string) => unknown; } : undefined;
394
+ } catch { /* 服务缺席/未就绪——rp 通道仍在 */ }
395
+ const get = (name: string): unknown => {
396
+ try { return hostUi?.get?.(name); } catch { return undefined; }
397
+ };
398
+ const fileNameOf = (p: string) => String(p || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() || p;
399
+ const openFile = (rp?.onOpenFile ?? get("onOpenFile")) as ((p: string, n?: string) => void) | undefined;
400
+ const openFolder = (rp?.onOpenFolder ?? get("onOpenFolder")) as ((p: string, n?: string) => void) | undefined;
401
+ return {
402
+ cwd: (rp?.cwd ?? get("selectedCwd")) as string | undefined,
403
+ gitRefreshKey: (rp?.gitRefreshKey ?? get("explorerRefreshKey")) as number | undefined,
404
+ onOpenFile: (p: string) => { try { openFile?.(p, fileNameOf(p)); } catch { /* noop */ } },
405
+ onOpenFolder: (p: string, name?: string) => { try { openFolder?.(p, name || fileNameOf(p)); } catch { /* noop */ } },
406
+ onAtMention: (rp?.onAtMention ?? get("onAtMention")) as HostFaceCbs["onAtMention"],
407
+ };
408
+ }
409
+
410
+ /** 三面共用内容 props(编排器经 DockStack slotExtra 透传;缺席回退本扩展 shim)。 */
411
+ type FolderFaceProps = {
412
+ path: string;
413
+ cwd?: string;
414
+ onOpenFile?: (path: string) => void;
415
+ onOpenFolder?: (path: string) => void;
416
+ onAtMention?: (relativePath: string, isDir: boolean) => void;
417
+ gitRefreshKey?: number;
418
+ };
419
+
420
+ /** 左栏栈 face(sidebarExplorerDock slot item render;gate = sidebar 族任意落位)。
421
+ * ⋮ 菜单与 dockable 全站统一(浮窗/左栏·区块/右栏·面板/上移/下移)+「钉住」+「关闭」
422
+ * (关闭 = 关面板并移出清单——FIFO 自动关闭的对外同款语义)。 */
423
+ export function FolderDockBlock(props: FolderFaceProps) {
424
+ const path = props.path;
425
+ const norm = path.replace(/[\\/]+$/, "");
426
+ const itemKey = folderItemKey(path);
427
+ const dockedZone = useGateSidebar(itemKey);
428
+ const pinned = useFolderPinned(path);
429
+ const showIgnored = useShowIgnored(path);
430
+ // v3 单行化(§2.8):按钮由本 face 声明(未挂载也能显示;点击未挂载命令 → store 展开+排队),
431
+ // 状态/命令/菜单项由 FolderBrowser 注册;标题位 = 目录切换器。
432
+ const st = useDockHeaderStateOf(itemKey);
433
+ if (!dockedZone) return null;
434
+ const title = lastSeg(norm) || norm;
435
+ const gitCount = typeof st.gitCount === "number" ? st.gitCount : 0;
436
+ const rootPath = typeof st.root === "string" && st.root ? st.root : norm;
437
+ const actions: DockHeaderAction[] = [
438
+ { id: "search", title: t("sidebar.searchFiles"), icon: <MiSearch/>, active: st.searchOpen === true, visibility: "always", onClick: () => { requestDockCommand(itemKey, "search"); } },
439
+ { id: "terminal", title: t("files.openTerminal"), icon: <TerminalIcon/>, visibility: "always", onClick: () => { requestDockCommand(itemKey, "terminal"); } },
440
+ { id: "refresh", title: t("sidebar.refreshExplorer"), icon: st.refreshDone === true ? <CheckIcon/> : <MiRefresh/>, done: st.refreshDone === true, visibility: "always", onClick: () => { requestDockCommand(itemKey, "refresh"); } },
441
+ { id: "plus", title: t("files.newFile") + " / " + t("files.uploadFile"), icon: <PlusIcon/>, visibility: "expanded", onClick: (ev?: unknown) => { requestDockCommand(itemKey, "plus", { anchor: anchorRectOf(ev) }); } },
442
+ { id: "changes", title: t("sidebar.changedFiles", { count: gitCount }), icon: <ChangesIcon/>, badge: gitCount > 0 ? gitCount : undefined, active: st.changesOpen === true, visibility: "expanded", onClick: () => { requestDockCommand(itemKey, "changes"); } },
443
+ { id: "at", title: t("files.insertPath"), icon: <span style={{ fontSize: 12, fontWeight: 700, lineHeight: 1 }}>@</span>, visibility: "expanded", onClick: () => { requestDockCommand(itemKey, "at"); } },
444
+ ];
445
+ return (
446
+ <DockBody
447
+ itemKey={itemKey}
448
+ blockKey={itemKey}
449
+ title={title}
450
+ chrome="section"
451
+ kind="dockable"
452
+ zones={[sidebarZoneOf(itemKey), "right-panel"]}
453
+ defaultZone={sidebarZoneOf(itemKey)}
454
+ headerActions={actions}
455
+ renderTitle={() => (
456
+ <FolderTitleSwitcher
457
+ path={rootPath}
458
+ hasParent={st.hasParent === true}
459
+ onPick={(p) => requestDockCommand(itemKey, "setRoot", p)}
460
+ onParent={() => requestDockCommand(itemKey, "parent")}
461
+ />
462
+ )}
463
+ extraMenuItems={[
464
+ { label: "钉住", icon: dockIconEl("pin", 13), active: pinned, alwaysRun: true, run: () => toggleFolderPin(path) },
465
+ { label: "显示忽略项", icon: <MiEye />, active: showIgnored, alwaysRun: true, run: () => toggleShowIgnored(path) },
466
+ ]}
467
+ onNewWindow={() => openFolderWindow(norm, props.cwd)}
468
+ onClose={() => removeFolderInstance(path)}
469
+ >
470
+ <FolderBrowserPane {...props} dockItemKey={itemKey} showIgnored={showIgnored} />
471
+ </DockBody>
472
+ );
473
+ }
474
+
475
+ /** 浮窗 face(dialogs slot item render;gate = floating)。标准浮窗壳(遮罩+面板),
476
+ * data-ext-float 属性交 aiti-float 接管(拖拽/缩放/位置持久化)。 */
477
+ export function FolderFloatFace(props: FolderFaceProps) {
478
+ const key = folderItemKey(props.path);
479
+ const open = useGate(key, "float");
480
+ // 钩子须无条件:放在 gate 早退之后,关闭态(1 钩)→ 打开态(2 钩)同实例重渲染即 React「钩子数变多」崩
481
+ const showIgnored = useShowIgnored(props.path);
482
+ if (!open) return null;
483
+ const norm = props.path.replace(/[\\/]+$/, "");
484
+ return (
485
+ <div
486
+ style={{ position: "fixed", inset: 0, zIndex: 311, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}
487
+ onClick={() => closePanel(key)}
488
+ >
489
+ <div
490
+ data-ext-float="1"
491
+ data-ext-float-key={key}
492
+ data-ext-float-dock={`${sidebarZoneOf(key)} right-panel`}
493
+ data-ext-float-dock-default={sidebarZoneOf(key)}
494
+ style={{
495
+ position: "fixed", width: "min(760px, 94vw)", height: "min(560px, 84vh)",
496
+ display: "flex", flexDirection: "column", overflow: "hidden",
497
+ background: "var(--bg-panel, #1a1a1a)", border: "1px solid var(--border, #444)",
498
+ borderRadius: 12, boxShadow: "0 18px 60px rgba(0,0,0,0.4)", color: "var(--text, #ddd)",
499
+ }}
500
+ onClick={(e: any) => e.stopPropagation()}
501
+ >
502
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 14px 6px", flexShrink: 0 }}>
503
+ <h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{lastSeg(norm) || norm}</h3>
504
+ <button
505
+ type="button" aria-label="显示忽略项" title={showIgnored ? "隐藏被忽略项" : "显示被忽略项"}
506
+ onClick={() => toggleShowIgnored(props.path)}
507
+ style={{ background: "none", border: "none", color: showIgnored ? "var(--accent, #2563eb)" : "var(--text-muted, #888)", cursor: "pointer", padding: "2px 6px", display: "inline-flex", alignItems: "center", fontFamily: "inherit" }}
508
+ ><MiEye /></button>
509
+ <button
510
+ type="button" aria-label="关闭" title="关闭" onClick={() => closePanel(key)}
511
+ style={{ background: "none", border: "none", color: "var(--text-muted, #888)", cursor: "pointer", fontSize: 14, padding: "2px 8px", fontFamily: "inherit" }}
512
+ >✕</button>
513
+ </div>
514
+ <FolderBrowserPane {...props} showIgnored={showIgnored} />
515
+ </div>
516
+ </div>
517
+ );
518
+ }
519
+
520
+ /** 右栏 tab face(rightFileDock slot item render;gate = docked:right-panel)。
521
+ * chrome="tab":无头行——页签行 ⇱(位置菜单)/✕ + 双击拆窗由宿主 TabBar 承担。
522
+ * collapsed 恒 false:折叠记忆按 itemKey 跨面共享(左栏区块播种默认折叠),
523
+ * 而 tab face 无头行可展开——不覆盖则 DockBody「!collapsed && children」恒空壳。 */
524
+ export function FolderDockRightFace(props: FolderFaceProps) {
525
+ const key = folderItemKey(props.path);
526
+ const open = useGate(key, { dock: "right-panel" });
527
+ // 同 FolderFloatFace:两个钩子必须在 gate 早退之前(条件钩子 = 重开 tab 即崩)
528
+ const showIgnored = useShowIgnored(props.path);
529
+ const pinned = useFolderPinned(props.path);
530
+ if (!open) return null;
531
+ return (
532
+ <DockBody
533
+ itemKey={key}
534
+ chrome="tab"
535
+ kind="dockable"
536
+ zones={[sidebarZoneOf(key), "right-panel"]}
537
+ defaultZone={sidebarZoneOf(key)}
538
+ collapsed={false}
539
+ extraMenuItems={[
540
+ { label: "钉住", icon: dockIconEl("pin", 13), active: pinned, alwaysRun: true, run: () => toggleFolderPin(props.path) },
541
+ { label: "显示忽略项", icon: <MiEye />, active: showIgnored, alwaysRun: true, run: () => toggleShowIgnored(props.path) },
542
+ ]}
543
+ onNewWindow={() => openFolderWindow(props.path.replace(/[\\/]+$/, ""), props.cwd)}
544
+ >
545
+ <FolderBrowserPane {...props} showIgnored={showIgnored} />
546
+ </DockBody>
547
+ );
548
+ }
549
+
550
+ /* ── 动态 faces 注册(aiti-folder entry 调用;instances 变化 → 重注册 + 差量 dispose)──
551
+ * 注册 id 约定(createDockFaces 同款):宿主 registerItems 自动拼 extId:itemId =
552
+ * itemKey —— raw id 必须不带扩展前缀(folder:<path>),拼后 = folderItemKey(p),
553
+ * 与 present 状态机/折叠/排序 key 精确一致;传带前缀 id 会双前缀断链(dock tab 链断裂)。
554
+ * registerExtUiItems 幂等覆盖不删旧项 → 收缩时对差量集合显式 disposeItems + 关面板
555
+ * (被挤出/移除实例若停在浮窗/右栏,宿主 tab/浮窗即时消隐)。 */
556
+ let prevFacePaths: string[] | null = null;
557
+ /** 常驻自动入栈由编排器驱动(stackApi 上线后 present,免「present 早于 float 状态机安装
558
+ * 落进降级 store」竞态);本模块只负责声明落位/默认折叠 + 三面注册。 */
559
+
560
+ /** 注册 raw id(无扩展前缀;宿主拼后 = folderItemKey)。 */
561
+ function folderRawId(path: string): string {
562
+ return `folder:${path.replace(/[\\/]+$/, "")}`;
563
+ }
564
+
565
+ /* ── faces 注册项按路径缓存(render/meta 引用恒定)───────────────────────────────
566
+ * 宿主 ExtSlot 以 `<it.render .../>` 渲染注册项——render 是**元素类型**,重注册换新闭包
567
+ * 会被 React 视作换组件类型 → 卸载重挂整棵子树(FolderBrowser 的 root/展开集/滚动/
568
+ * 搜索态全丢)。而 INSTANCES_EVENT 在「钉住」翻转时也触发本模块重注册——旧实现下点
569
+ * 一下钉住,文件夹就被打回清单登记路径(root 重新播种 root0),表现为「钉住没反应,
570
+ * folder 改回旧值」。缓存后同一路径的 render/meta 引用恒定:重注册时宿主
571
+ * registerExtUiItems 的 unchanged 判定成立(或仅差量项变)→ 既有区块零重挂。
572
+ * ⚠ 缓存不可因「路径清单未变」而跳过重注册(扩展 disable/import 复用同 URL 模块实例
573
+ * 时 modules 表虽被反卷、本模块内存仍在——跳过会导致 faces 永不回装);保留每次全量
574
+ * 重注册,靠引用恒定免重挂。
575
+ * faceOf 闭包只捕不可变路径 p;宿主 ctx 经模块级 folderExtCtx 读最新(重载后同步更新)。 */
576
+ type FolderFaceItem = { id: string; render: (rp: any) => unknown; meta?: Record<string, unknown> };
577
+ const faceItemsCache = new Map<string, { stack: FolderFaceItem; float: FolderFaceItem; right: FolderFaceItem }>();
578
+
579
+ function buildFaceItems(p: string): { stack: FolderFaceItem; float: FolderFaceItem; right: FolderFaceItem } {
580
+ const title = lastSeg(p.replace(/[\\/]+$/, "")) || p;
581
+ const rawId = folderRawId(p); // 宿主拼 extId: 前缀后 = folderItemKey(p)(注册 raw id 约定)
582
+ // 三面共用解析:插槽 rp → pi-web.ui 服务兜底(resolveHostFace,含 fileName 兜底包装)
583
+ const faceOf = (rp: any) => ({ path: p, ...resolveHostFace(rp) });
584
+ const stack: FolderFaceItem = {
585
+ id: rawId,
586
+ render: (rp: any) => {
587
+ const f = faceOf(rp);
588
+ return <FolderDockBlock path={f.path} cwd={f.cwd} onOpenFile={f.onOpenFile} onOpenFolder={f.onOpenFolder} onAtMention={f.onAtMention} gitRefreshKey={f.gitRefreshKey} />;
589
+ },
590
+ meta: { title, priority: 2 },
591
+ };
592
+ const float: FolderFaceItem = {
593
+ id: rawId,
594
+ render: (rp: any) => {
595
+ const f = faceOf(rp);
596
+ return <FolderFloatFace path={f.path} cwd={f.cwd} onOpenFile={f.onOpenFile} onOpenFolder={f.onOpenFolder} onAtMention={f.onAtMention} gitRefreshKey={f.gitRefreshKey} />;
597
+ },
598
+ meta: { label: "文件夹", title },
599
+ };
600
+ const right: FolderFaceItem = {
601
+ id: rawId,
602
+ render: (rp: any) => {
603
+ const f = faceOf(rp);
604
+ return <FolderDockRightFace path={f.path} cwd={f.cwd} onOpenFile={f.onOpenFile} onOpenFolder={f.onOpenFolder} onAtMention={f.onAtMention} gitRefreshKey={f.gitRefreshKey} />;
605
+ },
606
+ meta: { title },
607
+ };
608
+ const entry = { stack, float, right };
609
+ faceItemsCache.set(p, entry);
610
+ return entry;
611
+ }
612
+
613
+ function faceItemsOf(p: string) {
614
+ return faceItemsCache.get(p) ?? buildFaceItems(p);
615
+ }
616
+
617
+ export function syncFolderDockFaces(ctx: Record<string, unknown>): void {
618
+ folderExtCtx = ctx; // face 渲染时读宿主 pi-web.ui 服务用(resolveHostFace)
619
+ const register = ctx.registerItems as ((slot: string, items: FolderFaceItem[]) => void) | undefined;
620
+ const dispose = ctx.disposeItems as ((slot: string, ids: string[]) => void) | undefined;
621
+ migrateFolderCollapseKeys();
622
+ const paths = readFolderInstances();
623
+ const stackItems: FolderFaceItem[] = [];
624
+ const floatItems: FolderFaceItem[] = [];
625
+ const rightItems: FolderFaceItem[] = [];
626
+ for (const p of paths) {
627
+ declareFolderInstance(p);
628
+ const { stack, float, right } = faceItemsOf(p);
629
+ stackItems.push(stack);
630
+ floatItems.push(float);
631
+ rightItems.push(right);
632
+ }
633
+ if (prevFacePaths) {
634
+ for (const p of prevFacePaths) {
635
+ if (paths.includes(p)) continue;
636
+ faceItemsCache.delete(p); // 移除路径:渲染闭包缓存一并回收(重新加入时重建)
637
+ const raw = folderRawId(p);
638
+ try { dispose?.("sidebarExplorerDock", [raw]); dispose?.("dialogs", [raw]); dispose?.("rightFileDock", [raw]); } catch { /* ignore */ }
639
+ try { present(folderItemKey(p), "close"); } catch { /* ignore */ }
640
+ }
641
+ }
642
+ try { register?.("sidebarExplorerDock", stackItems); } catch { /* ignore */ }
643
+ try { register?.("dialogs", floatItems); } catch { /* ignore */ }
644
+ try { register?.("rightFileDock", rightItems); } catch { /* ignore */ }
645
+ prevFacePaths = paths;
646
+ }