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

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,212 @@
1
+ /**
2
+ * aiti-folder · 项目/目录切换器(FolderSwitcher)——自手写版迁移 + 组件化重构
3
+ *
4
+ * 同款左上角项目切换器:历史目录(/api/sessions,已知 workspace 优先)+ 过滤 +
5
+ * 运行/未读状态图标 + 使用默认目录(/api/default-cwd)+ 自定义路径浏览(/api/cwd/browse)。
6
+ * 两种形态:compact(顶栏 36×36 图标按钮)/ 非 compact(FolderBrowser 头部全宽按钮)。
7
+ * 唯一约定:选中只调 onSelect(改本调用方展示),不切 cwd。
8
+ *
9
+ * WorkspaceDropdown:弹出菜单部分抽成独立导出组件——其他扩展 client(如
10
+ * session-manager 的目录过滤)可构建期内联复用同一弹窗(见其 entry.tsx 的
11
+ * host-shim ctx 双注入约定)。selected 为当前高亮路径;onSelect 只回传不落库。
12
+ */
13
+ import { useState, useEffect, useRef, useLayoutEffect } from "react";
14
+ import { clampToViewport, fixedOrigin } from "../../../_shared/popup-anchor";
15
+ import { dedupeFetch } from "../../../_shared/dedupe-fetch";
16
+ import { apiUrl, extSvc } from "./host-shim";
17
+ import { ct as t } from "./comp-i18n"; // 组件自带多语:内置字典优先,回落宿主 t
18
+ import { displayCwd, loadUnread, recentProjects, projectActivity } from "./lib";
19
+ import { FolderIcon, CaretIcon, CheckIcon, PathLabel, ActivityBadges } from "./icons";
20
+ import { DirectoryBrowser } from "./directory-browser";
21
+
22
+ /** 弹出菜单(已知 workspace 列表优先)。需挂在 position:relative 容器内。
23
+ * allOption:传入则在列表顶部渲染「全部」行,选中回传 onSelect("")。
24
+ * width:可选,覆盖菜单宽度(默认左对齐 = 内容自然宽,下限触发器宽、上限 min(480px, 视口-16px);
25
+ * 右对齐 min(320px,80vw));长路径场景可传如 "min(480px,90vw)" 显示全。 */
26
+ export function WorkspaceDropdown({ selected, onSelect, onClose, align = "left", showUseDefault = true, allOption, width, extraTopItem }: {
27
+ selected?: string;
28
+ onSelect: (p: string) => void;
29
+ onClose: () => void;
30
+ align?: "left" | "right";
31
+ showUseDefault?: boolean;
32
+ allOption?: { label: string };
33
+ width?: number | string;
34
+ /** 列表顶部附加项(dock 头目录切换器的「上一级」等) */
35
+ extraTopItem?: { label: string; onClick: () => void };
36
+ }) {
37
+ const selectedStr = selected || "";
38
+ const [home, setHome] = useState("");
39
+ const [projects, setProjects] = useState<{ key: string; root: string }[]>([]);
40
+ const [filter, setFilter] = useState("");
41
+ const [activity, setActivity] = useState(new Map());
42
+ const [pickerOpen, setPickerOpen] = useState(false);
43
+ const [busy, setBusy] = useState(false);
44
+
45
+ // —— 弹层一律固定定位(2026-09:不再受任何容器钳制)——旧版 absolute 锚定 + 越视口才
46
+ // 转 fixed 的兜底,探测不到祖先裁剪:左栏 sidebar-container(宽度动画 overflow:hidden)
47
+ // → StackCell → DockBody 全链 hidden,弹层 rect 仍在视口内但被裁没(点了显示不出来)。
48
+ // 现一律 position:fixed 锚定触发按钮矩形(父级 position:relative wrapper):渲染后测量
49
+ // 触发器/自身 → 宽度落定(显式 width 优先 / 右锚定 min(320px,80vw) / 左锚定按内容自然宽,
50
+ // 下限=触发器宽、上限=min(480px, 视口-16px)——窄触发器不再把路径截断)→ 下开(底部装不下
51
+ // 且上方空间更大则上翻)→ 视口钳制;transformed 祖先构成 fixed
52
+ // containing block 时按其原点校正(popup-anchor.fixedOrigin)。每渲染复测(projects
53
+ // 异步到齐后高度变化跟随重钳),值不变即 bail 不循环;useLayoutEffect 绘制前同步,无闪烁。
54
+ const ddRef = useRef<HTMLDivElement | null>(null);
55
+ const filterRef = useRef<HTMLInputElement | null>(null);
56
+ const [pos, setPos] = useState<{ left: number; top: number; width: number } | null>(null);
57
+ useLayoutEffect(() => {
58
+ const el = ddRef.current;
59
+ const anchor = el?.parentElement;
60
+ if (!el || !anchor) return;
61
+ const a = anchor.getBoundingClientRect();
62
+ const gap = 4, pad = 8;
63
+ if (width !== undefined) el.style.width = typeof width === "number" ? `${width}px` : width;
64
+ else if (align === "right") el.style.width = "min(320px,80vw)";
65
+ else {
66
+ // 左锚定不再拉伸至触发器宽(窄触发器 → 路径全截断):先以 max-content 量内容自然宽,
67
+ // 最终 = clamp(自然宽, min(触发器宽, maxW), maxW),maxW = min(480px, 视口-2pad)。
68
+ el.style.width = "max-content";
69
+ const natural = el.getBoundingClientRect().width;
70
+ const maxW = Math.min(480, window.innerWidth - pad * 2);
71
+ el.style.width = `${Math.min(Math.max(natural, Math.min(a.width, maxW)), maxW)}px`;
72
+ }
73
+ const m = el.getBoundingClientRect();
74
+ let left = align === "right" ? a.right - m.width : a.left;
75
+ let top = a.bottom + gap;
76
+ const below = window.innerHeight - pad - top;
77
+ const above = a.top - gap - pad;
78
+ if (m.height > below && above > below) top = a.top - gap - m.height; // 上翻
79
+ const c = clampToViewport(left, top, m.width, m.height, pad);
80
+ const o = fixedOrigin(el);
81
+ const next = { left: c.left - o.x, top: c.top - o.y, width: m.width };
82
+ setPos((cur) => cur && cur.left === next.left && cur.top === next.top && cur.width === next.width ? cur : next);
83
+ });
84
+ // fixed 弹层不随内容滚动(滞留旧位 = 错位)——任意容器滚动即关;弹层内部滚动(项目列表/
85
+ // DirectoryBrowser)豁免。
86
+ useEffect(() => {
87
+ const onScroll = (e: Event) => {
88
+ if (ddRef.current && e.target instanceof Node && ddRef.current.contains(e.target)) return;
89
+ onClose();
90
+ };
91
+ window.addEventListener("scroll", onScroll, true);
92
+ return () => window.removeEventListener("scroll", onScroll, true);
93
+ }, [onClose]);
94
+ // home 目录:优先宿主共享服务 pi-web.home(lib/home-dir 单例,跨全部消费面一次请求);
95
+ // 旧宿主/服务缺席 → 回退自拉(dedupeFetch 兜同窗口多实例)。
96
+ useEffect(()=>{ let alive = true;
97
+ const svc = extSvc<{ ensure: () => Promise<string | null> }>("pi-web.home");
98
+ const p = svc?.ensure
99
+ ? svc.ensure()
100
+ : dedupeFetch(apiUrl("/api/home")).then(r=>r.json()).then((d: any)=> (d?.home as string) ?? null).catch(()=>null);
101
+ void p.then((dir)=>{ if (alive && dir) setHome(dir); });
102
+ return ()=>{ alive = false; };
103
+ }, []);
104
+ useEffect(()=>{ const unread=loadUnread();
105
+ dedupeFetch(apiUrl("/api/sessions"),{cache:"no-store"}).then(r=>r.json()).then(d=>{ const s=d.sessions||[]; setProjects(recentProjects(s)); setActivity(projectActivity(s, new Set(d.runningSessionIds||[]), unread)); }).catch(()=>{});
106
+ }, []);
107
+ const showFilter = projects.length>=8;
108
+ // 筛选框聚焦:仅非小屏自动聚焦(≤640px 手机/窄屏不弹键盘遮挡列表;用户点输入框再聚焦)
109
+ useEffect(() => {
110
+ if (!showFilter) return;
111
+ if (typeof window !== "undefined" && window.matchMedia("(max-width: 640px)").matches) return;
112
+ filterRef.current?.focus();
113
+ }, [showFilter]);
114
+ const visible = showFilter && filter.trim() ? projects.filter(p=>displayCwd(p.root,home)!.toLowerCase().includes(filter.trim().toLowerCase())) : projects;
115
+ const handleDefault = async ()=>{ setBusy(true); try{ const r=await fetch(apiUrl("/api/default-cwd"),{method:"POST"}); const d=await r.json(); if(d.cwd){ onSelect(d.cwd); onClose();} }catch{} finally{ setBusy(false);} };
116
+ const dropdownStyle: Record<string, any> = {
117
+ position: "fixed", zIndex: 100, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 8,
118
+ boxShadow: "0 6px 20px rgba(0,0,0,0.10)", overflow: "hidden",
119
+ // 首帧离屏(布局期测量后落位,绘制前同步无闪烁)
120
+ ...(pos ? { left: pos.left, top: pos.top, width: pos.width } : { left: -99999, top: -99999 }),
121
+ };
122
+ return (
123
+ <div ref={ddRef} style={dropdownStyle}>
124
+ {showFilter ? (
125
+ <div style={{ padding:"6px 8px", borderBottom:"1px solid var(--border)" }}>
126
+ <input ref={filterRef} value={filter} onChange={e=>setFilter(e.target.value)} onKeyDown={e=>{ if(e.key==="Escape"){ setFilter(""); onClose(); } }} placeholder={t("sidebar.filterProjects")}
127
+ style={{ width:"100%", fontSize:11, fontFamily:"var(--font-mono)", padding:"5px 8px", border:"1px solid var(--border)", borderRadius:5, outline:"none", background:"var(--bg)", color:"var(--text)", boxSizing:"border-box" }} />
128
+ </div>
129
+ ) : null}
130
+ <div style={{ maxHeight:"min(50vh,380px)", overflowY:"auto" }}>
131
+ {extraTopItem ? (
132
+ <button key="__extra_top__" type="button" onClick={()=>extraTopItem.onClick()}
133
+ style={{ display:"flex", alignItems:"center", gap:7, width:"100%", padding:"8px 10px", background:"var(--bg)", borderLeft:"none", borderRight:"none", borderTop:"none", borderBottom:"1px solid var(--border)", color:"var(--text-muted)", cursor:"pointer", textAlign:"left", fontSize:11 }}>
134
+ <PathLabel text={extraTopItem.label} style={{flex:1}} />
135
+ </button>
136
+ ) : null}
137
+ {allOption ? (
138
+ <button key="__all__" type="button" onClick={()=>{ onSelect(""); setFilter(""); onClose(); }}
139
+ style={{ display:"flex", alignItems:"center", gap:7, width:"100%", padding:"8px 10px", background:"var(--bg)", borderLeft:"none", borderRight:"none", borderTop:"none", borderBottom:"1px solid var(--border)", color: !selectedStr ? "var(--text)" : "var(--text-muted)", cursor:"pointer", textAlign:"left", fontSize:11 }}>
140
+ {!selectedStr ? <CheckIcon /> : <span style={{width:10,flexShrink:0}} />}
141
+ <FolderIcon open={false} />
142
+ <PathLabel text={allOption.label} style={{flex:1}} />
143
+ </button>
144
+ ) : null}
145
+ {visible.map(p=>{ const cur = p.root===selectedStr; return (
146
+ <button key={p.key} type="button" onClick={()=>{ onSelect(p.root); setFilter(""); onClose(); }} title={p.root}
147
+ style={{ display:"flex", alignItems:"center", gap:7, width:"100%", padding:"8px 10px", background:"var(--bg)", borderLeft:"none", borderRight:"none", borderTop:"none", borderBottom:"1px solid var(--border)", color:cur?"var(--text)":"var(--text-muted)", cursor:"pointer", textAlign:"left", fontSize:11, fontFamily:"var(--font-mono)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
148
+ {cur?<CheckIcon />:<span style={{width:10,flexShrink:0}} />}
149
+ <PathLabel text={displayCwd(p.root,home)} style={{flex:1}} />
150
+ <ActivityBadges act={activity.get(p.key)} t={t} />
151
+ </button>
152
+ ); })}
153
+ {visible.length===0 && filter.trim() ? <div style={{ padding:"8px 10px", fontSize:11, color:"var(--text-dim)" }}>{t("sidebar.noMatchingProjects")}</div> : null}
154
+ </div>
155
+ {showUseDefault ? (
156
+ <button type="button" onClick={e=>{ e.stopPropagation(); void handleDefault(); }} disabled={busy}
157
+ style={{ display:"flex", alignItems:"center", gap:7, width:"100%", padding:"8px 10px", background:"none", borderLeft:"none", borderBottom:"none", borderTop:visible.length>0?"1px solid var(--border)":"none", color:"var(--text-muted)", cursor:"pointer", textAlign:"left", fontSize:11 }}>
158
+ <svg width={10} height={10} viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth={1.1} strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}}><path d="M1 3A1 1 0 0 1 2 2H4L5 3.5H8.5a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-7A.5.5 0 0 1 1 8V3Z"/></svg>
159
+ {t("sidebar.useDefaultDirectory")}
160
+ </button>
161
+ ) : null}
162
+ <button type="button" onClick={e=>{ e.stopPropagation(); setPickerOpen(true); }}
163
+ style={{ display:"flex", alignItems:"center", gap:7, width:"100%", padding:"8px 10px", background:"none", border:"none", color:"var(--text-muted)", cursor:"pointer", textAlign:"left", fontSize:11 }}>
164
+ <svg width={10} height={10} viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth={1.1} strokeLinecap="round" style={{flexShrink:0}}><line x1="5" y1="1" x2="5" y2="9"/><line x1="1" y1="5" x2="9" y2="5"/></svg>
165
+ {t("sidebar.customPath")}
166
+ </button>
167
+ {pickerOpen ? <DirectoryBrowser initialPath={selectedStr} onSelect={(p)=>{ setPickerOpen(false); onClose(); onSelect(p); }} onCancel={()=>setPickerOpen(false)} /> : null}
168
+ </div>
169
+ );
170
+ }
171
+
172
+ export function FolderSwitcher({ path, onSelect, compact }: { path?: string; onSelect?: (p: string) => void; compact?: boolean }) {
173
+ const pathStr = path || ""; const onSelectFn = onSelect || (()=>{}); const isCompact = compact === true;
174
+ const [open, setOpen] = useState(false);
175
+ const [home, setHome] = useState("");
176
+ const ddRef = useRef<HTMLDivElement | null>(null);
177
+ useEffect(()=>{ let alive = true;
178
+ const svc = extSvc<{ ensure: () => Promise<string | null> }>("pi-web.home");
179
+ const p = svc?.ensure
180
+ ? svc.ensure()
181
+ : dedupeFetch(apiUrl("/api/home")).then(r=>r.json()).then((d: any)=> (d?.home as string) ?? null).catch(()=>null);
182
+ void p.then((dir)=>{ if (alive && dir) setHome(dir); });
183
+ return ()=>{ alive = false; };
184
+ }, []);
185
+ useEffect(()=>{ if(!open) return; const h2=(e:any)=>{ if(ddRef.current && !ddRef.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown",h2); return ()=>document.removeEventListener("mousedown",h2); }, [open]);
186
+ const triggerStyle: Record<string, any> = isCompact
187
+ ? { display:"flex", alignItems:"center", justifyContent:"center", width:36, height:36, padding:0, background:"var(--bg-selected)", borderTop:"none", borderRight:"none", borderBottom:"none", borderLeft:"1px solid var(--border)", color:"var(--text)", cursor:"pointer", flexShrink:0, transition:"color 0.12s" }
188
+ : { width:"100%", display:"flex", alignItems:"center", gap:6, padding:"6px 10px", background:"var(--bg-hover)", border:"1px solid var(--border)", borderRadius:7, cursor:"pointer", fontSize:12, color:"var(--text)", textAlign:"left", transition:"border-color 0.15s, background 0.15s" };
189
+ return (
190
+ <div ref={ddRef} style={{ position:"relative", flex:isCompact?undefined:"0 1 auto", minWidth:isCompact?undefined:0, maxWidth:isCompact?undefined:"60%" }}>
191
+ <button type="button" onClick={()=>setOpen(v=>!v)} title={isCompact?t("files.openFolder"):pathStr} aria-label={isCompact?t("files.openFolder"):undefined} style={triggerStyle}>
192
+ {isCompact ? (
193
+ <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
194
+ <path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
195
+ <line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" />
196
+ </svg>
197
+ ) : null}
198
+ {!isCompact ? <FolderIcon open={true} /> : null}
199
+ {!isCompact ? <PathLabel text={displayCwd(pathStr, home)} style={{ flex:1, fontFamily:"var(--font-mono)", fontSize:11, color:"var(--text)" }} /> : null}
200
+ {!isCompact ? <CaretIcon /> : null}
201
+ </button>
202
+ {open ? (
203
+ <WorkspaceDropdown
204
+ selected={pathStr}
205
+ onSelect={(p)=>{ onSelectFn(p); setOpen(false); }}
206
+ onClose={()=>setOpen(false)}
207
+ align={isCompact ? "right" : "left"}
208
+ />
209
+ ) : null}
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * git-plan · CommitPlanDialog(git-ops 融合方案 M4;原 folder 案 §5 交互流的用户主导版)。
3
+ *
4
+ * - 打开:引擎 refs 重探(fresh dirtyFiles)→ 启发式分组(folder server,含偏好);
5
+ * 若 state 层已有本仓 plan 且组文件仍 ⊆ 当前脏集 → 恢复用户上次编辑。
6
+ * - 编辑:组消息改写 / 文件「→」移到下一组(学 dirKey 共现)/ 「✕」剔除(学路径)/ 恢复 / 新建组。
7
+ * - 逐笔提交:引擎 `/git/plan/execute`(逐组 add→commit,失败即停返回 completed/failedGroup);
8
+ * 失败 → 断点 UI(卡在第 N 组)+「回滚本计划」= 引擎 `/git/plan/rollback`(reset --mixed baseHEAD)。
9
+ * - AI 精修(两级第二级)留待 aiti-git-review aiReview services 落地(同步取模型输出通道未拍板)。
10
+ */
11
+ import { useCallback, useEffect, useMemo, useState } from "react";
12
+ import { apiUrl } from "./host-shim";
13
+ import { ct } from "./comp-i18n";
14
+ import { createGitOpsApi } from "../../../_shared/git-ops-client/api.mjs";
15
+ import type { FolderGit } from "./git-toolbar";
16
+
17
+ interface PlanGroupEdit { key: string; message: string; files: string[]; note?: string }
18
+
19
+ const statusOf = (xy: string): string => {
20
+ if (xy === "??") return "untracked";
21
+ if (xy.includes("D")) return "deleted";
22
+ if (xy.includes("A")) return "added";
23
+ if (xy.includes("R") || xy.includes("C")) return "renamed";
24
+ return "modified";
25
+ };
26
+
27
+ const groupColor = (i: number): string => `color-mix(in srgb, var(--accent) ${[85, 60, 40, 25][i % 4]}%, var(--text))`;
28
+
29
+ export function CommitPlanDialog(p: { root: string; git: FolderGit; onClose: () => void }) {
30
+ const api = useMemo(() => createGitOpsApi({ apiUrl }), []);
31
+ const [groups, setGroups] = useState<PlanGroupEdit[] | null>(null);
32
+ const [excluded, setExcluded] = useState<string[]>([]);
33
+ const [baseHEAD, setBaseHEAD] = useState("");
34
+ const [busy, setBusy] = useState(false);
35
+ const [aiBusy, setAiBusy] = useState(false);
36
+ const [aiAvail, setAiAvail] = useState(false);
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [result, setResult] = useState<{ completed: number; failedGroup: number | null; shas: string[] } | null>(null);
39
+ const [rolled, setRolled] = useState(false);
40
+
41
+ // 打开:重探 refs → 启发式 → 与已存 plan 合并(组文件仍 ⊆ 当前脏集才恢复)
42
+ useEffect(() => {
43
+ let cancelled = false;
44
+ (async () => {
45
+ const r = await api.refs(p.root);
46
+ if (cancelled) return;
47
+ const dirty: { xy: string; path: string }[] = r.data?.dirtyFiles ?? [];
48
+ setBaseHEAD(r.data?.head ?? "");
49
+ const files = dirty.map((f) => ({ path: f.path, status: statusOf(f.xy) }));
50
+ const g = await fetch(apiUrl("/api/extensions/aiti-folder/git-plan-group"), {
51
+ method: "POST", headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ cwd: p.root, files }),
53
+ }).then((x) => x.json()).catch(() => null);
54
+ if (cancelled) return;
55
+ setAiAvail(!!g?.aiRefine);
56
+ let next: PlanGroupEdit[] = (g?.groups ?? []).map((x: any, i: number) => ({ key: x.key ?? `g${i}`, message: x.message ?? "", files: x.files ?? [] }));
57
+ let exc: string[] = g?.excluded ?? [];
58
+ const saved = await fetch(apiUrl("/api/extensions/aiti-folder/git-plan-get"), {
59
+ method: "POST", headers: { "content-type": "application/json" },
60
+ body: JSON.stringify({ cwd: p.root }),
61
+ }).then((x) => x.json()).catch(() => null);
62
+ const sp = saved?.plan;
63
+ if (sp && Array.isArray(sp.groups) && sp.groups.length > 0 && sp.baseHEAD === (r.data?.head ?? "")) {
64
+ const dirtySet = new Set(files.map((f) => f.path));
65
+ const restored = sp.groups
66
+ .map((sg: any) => ({ key: sg.id ?? "g", message: sg.message ?? "", files: (sg.files ?? []).filter((f: string) => dirtySet.has(f)) }))
67
+ .filter((sg: any) => sg.files.length > 0);
68
+ if (restored.length > 0) {
69
+ next = restored;
70
+ exc = sp.excluded ?? exc;
71
+ }
72
+ }
73
+ setGroups(next);
74
+ setExcluded(exc);
75
+ })().catch(() => { if (!cancelled) { setError(ct("i18n.networkError")); setGroups([]); setExcluded([]); } });
76
+ return () => { cancelled = true; };
77
+ }, [p.root, api]);
78
+
79
+ const learn = useCallback((action: "move" | "exclude", payload: Record<string, string>) => {
80
+ void fetch(apiUrl("/api/extensions/aiti-folder/git-plan-learn"), {
81
+ method: "POST", headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ cwd: p.root, action, ...payload }),
83
+ }).catch(() => {});
84
+ }, [p.root]);
85
+
86
+ const moveFile = useCallback((fromGroup: number, file: string) => {
87
+ setGroups((prev) => {
88
+ if (!prev || prev.length < 2) return prev;
89
+ const next = prev.map((g) => ({ ...g, files: g.files.filter((f) => f !== file) }));
90
+ const to = (fromGroup + 1) % prev.length;
91
+ next[to].files.push(file);
92
+ const kept = next.filter((g) => g.files.length > 0);
93
+ learn("move", { file, toKey: kept[to >= kept.length ? 0 : to]?.key ?? next[to].key });
94
+ return kept.length > 0 ? kept : next;
95
+ });
96
+ }, [learn]);
97
+
98
+ const excludeFile = useCallback((file: string) => {
99
+ setGroups((prev) => prev ? prev.map((g) => ({ ...g, files: g.files.filter((f) => f !== file) })).filter((g) => g.files.length > 0) : prev);
100
+ setExcluded((prev) => [...prev, file]);
101
+ learn("exclude", { path: file });
102
+ }, [learn]);
103
+
104
+ const restoreFile = useCallback((file: string) => {
105
+ setExcluded((prev) => prev.filter((f) => f !== file));
106
+ setGroups((prev) => {
107
+ if (!prev || prev.length === 0) return prev;
108
+ const next = [...prev];
109
+ next[0] = { ...next[0], files: [...next[0].files, file] };
110
+ return next;
111
+ });
112
+ }, []);
113
+
114
+ const addGroup = useCallback(() => {
115
+ setGroups((prev) => {
116
+ if (!prev || prev.length === 0) return prev;
117
+ const donor = prev.reduce((a, b) => (b.files.length > a.files.length ? b : a), prev[0]);
118
+ const file = donor.files[donor.files.length - 1];
119
+ if (!file) return prev;
120
+ return [...prev.map((g) => g === donor ? { ...g, files: g.files.slice(0, -1) } : g).filter((g) => g.files.length > 0),
121
+ { key: "g" + Date.now(), message: ct("git.planNewGroupMessage"), files: [file] }];
122
+ });
123
+ }, []);
124
+
125
+ const aiRefine = useCallback(async () => {
126
+ if (!groups || aiBusy) return;
127
+ setAiBusy(true); setError(null);
128
+ try {
129
+ // basePath 从 apiUrl 推导(host-shim 前缀 /dev 等);origin 本页已知
130
+ const p1 = apiUrl("/api/agent/new");
131
+ const basePath = p1.slice(0, Math.max(0, p1.indexOf("/api/")));
132
+ const r = await fetch(apiUrl("/api/extensions/aiti-folder/git-plan-ai-refine"), {
133
+ method: "POST", headers: { "content-type": "application/json" },
134
+ body: JSON.stringify({ cwd: p.root, origin: location.origin, basePath, groups, excluded }),
135
+ }).then((x) => x.json());
136
+ if (!r?.ok) { setError(r?.error || "AI refine failed"); return; }
137
+ const res = r.result as { groups: any[]; excluded: string[] };
138
+ setGroups(res.groups.map((x: any, i: number) => ({ key: x.key ?? `ai-${i}`, message: x.message ?? "", files: x.files ?? [], note: x.note || undefined })));
139
+ setExcluded(res.excluded ?? []);
140
+ } catch (e) {
141
+ setError(e instanceof Error ? e.message : String(e));
142
+ } finally {
143
+ setAiBusy(false);
144
+ }
145
+ }, [groups, aiBusy, p.root, excluded]);
146
+
147
+ const execute = useCallback((startIdx = 0) => {
148
+ if (!groups || busy) return;
149
+ const remaining = groups.slice(startIdx);
150
+ if (remaining.length === 0) return;
151
+ const messages = remaining.map((g, i) => `${startIdx + i + 1}. ${g.message} (${g.files.length})`).join("\n");
152
+ if (!window.confirm(ct("git.planConfirm", { count: remaining.length }) + "\n" + messages)) return;
153
+ setBusy(true); setError(null); setResult(null); setRolled(false);
154
+ (async () => {
155
+ // plan 落盘(state 层)→ 引擎逐笔执行(断点语义)
156
+ await fetch(apiUrl("/api/extensions/aiti-folder/git-plan-save"), {
157
+ method: "POST", headers: { "content-type": "application/json" },
158
+ body: JSON.stringify({ cwd: p.root, baseHEAD, groups: groups.map((g) => ({ id: g.key, files: g.files, message: g.message, state: "pending" })), excluded }),
159
+ }).catch(() => {});
160
+ const r = await api.planExecute(p.root, remaining.map((g) => ({ files: g.files, message: g.message })));
161
+ if (r.status !== 200) { setError(r.data?.error ?? "HTTP " + r.status); return; }
162
+ const d = r.data as { ok: boolean; completed: number; failedGroup: number | null; shas: string[]; error?: string };
163
+ const failedAbs = d.failedGroup != null ? startIdx + d.failedGroup : null;
164
+ setResult({ completed: startIdx + d.completed, failedGroup: failedAbs, shas: d.shas ?? [] });
165
+ if (d.ok) {
166
+ p.git.refresh();
167
+ p.onClose();
168
+ } else {
169
+ setError(d.error ?? "");
170
+ }
171
+ })().finally(() => setBusy(false));
172
+ }, [groups, busy, api, p.root, p.git, p.onClose, baseHEAD, excluded]);
173
+
174
+ const rollback = useCallback(() => {
175
+ if (!baseHEAD || busy) return;
176
+ setBusy(true);
177
+ (async () => {
178
+ const r = await api.planRollback(p.root, baseHEAD);
179
+ if (r.status !== 200) { setError(r.data?.error ?? "HTTP " + r.status); return; }
180
+ setRolled(true);
181
+ setResult(null);
182
+ p.git.refresh();
183
+ })().finally(() => setBusy(false));
184
+ }, [baseHEAD, busy, api, p.root, p.git]);
185
+
186
+ const totalFiles = (groups ?? []).reduce((t, g) => t + g.files.length, 0);
187
+
188
+ return (
189
+ <div onClick={(e) => { if (e.target === e.currentTarget && !busy) p.onClose(); }}
190
+ style={{ position: "fixed", inset: 0, zIndex: 900, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}>
191
+ <div style={{ width: 430, maxWidth: "94vw", maxHeight: "84vh", display: "flex", flexDirection: "column", borderRadius: 10, background: "var(--bg)", border: "1px solid var(--border)", boxShadow: "0 14px 36px color-mix(in srgb, var(--text) 16%, transparent)", fontSize: 12 }}>
192
+ <div style={{ display: "flex", alignItems: "center", padding: "10px 12px", borderBottom: "1px solid var(--border)", fontWeight: 600, gap: 6 }}>
193
+ {ct("git.planTitle")}
194
+ {groups ? <span style={{ color: "var(--text-dim)", fontSize: 10.5, fontWeight: 400, fontFamily: "var(--font-mono)" }}>{groups.length} / {totalFiles}</span> : null}
195
+ <span style={{ flex: 1 }} />
196
+ <button type="button" onClick={p.onClose} style={{ border: "none", background: "none", color: "var(--text-dim)", cursor: "pointer", fontSize: 12 }}>✕</button>
197
+ </div>
198
+ <div style={{ flex: "1 1 auto", minHeight: 0, overflowY: "auto", padding: "8px 12px" }}>
199
+ {!groups ? <div style={{ color: "var(--text-dim)", fontSize: 11, padding: "8px 0" }}>…</div> : (
200
+ <>
201
+ {groups.map((g, gi) => (
202
+ <div key={g.key + gi} style={{ border: "1px solid var(--border)", borderRadius: 8, padding: "7px 9px", marginBottom: 7, background: "var(--bg-hover)" }}>
203
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 5 }}>
204
+ <span style={{ width: 8, height: 8, borderRadius: "50%", background: groupColor(gi), flexShrink: 0 }} />
205
+ <input value={g.message} onChange={(e) => setGroups((prev) => prev ? prev.map((x, xi) => xi === gi ? { ...x, message: e.target.value } : x) : prev)}
206
+ placeholder={ct("git.commitPlaceholder")}
207
+ style={{ flex: 1, minWidth: 0, height: 24, boxSizing: "border-box" as const, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", fontSize: 11, fontFamily: "var(--font-mono)", padding: "0 7px", outline: "none" }} />
208
+ {result && gi < result.completed ? <span title="committed" style={{ color: "var(--text-dim)", fontSize: 10, flexShrink: 0 }}>✓</span> : null}
209
+ {g.note ? <span title={g.note} style={{ fontSize: 10, flexShrink: 0, color: "var(--text-dim)", maxWidth: 90, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" as const }}>{g.note.startsWith("✓") ? "" : "⚠ "}{g.note}</span> : null}
210
+ </div>
211
+ {g.files.map((f) => (
212
+ <div key={f} style={{ display: "flex", alignItems: "center", gap: 6, padding: "1.5px 0 1.5px 14px", fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--text-muted)" }}>
213
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" as const, flex: 1, minWidth: 0 }}>{f}</span>
214
+ {groups.length > 1 ? (
215
+ <button type="button" title={ct("git.planMoveTo")} onClick={() => moveFile(gi, f)} style={planIconBtn()}>→</button>
216
+ ) : null}
217
+ <button type="button" title={ct("git.planExclude")} onClick={() => excludeFile(f)} style={planIconBtn()}>✕</button>
218
+ </div>
219
+ ))}
220
+ </div>
221
+ ))}
222
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
223
+ <button type="button" onClick={addGroup} disabled={totalFiles === 0}
224
+ style={{ border: "none", background: "none", color: "var(--text-dim)", cursor: totalFiles === 0 ? "default" : "pointer", fontSize: 11, padding: "2px 0" }}>
225
+ {ct("git.planAddGroup")}
226
+ </button>
227
+ {aiAvail ? (
228
+ <button type="button" onClick={() => void aiRefine()} disabled={aiBusy || !groups}
229
+ style={{ border: "none", background: "none", color: aiBusy ? "var(--text-dim)" : "var(--accent)", cursor: aiBusy ? "default" : "pointer", fontSize: 11, padding: "2px 0" }}>
230
+ {aiBusy ? ct("git.aiRefineBusy") : ct("git.aiRefine")}
231
+ </button>
232
+ ) : null}
233
+ </div>
234
+ {excluded.length > 0 ? (
235
+ <div style={{ marginTop: 6, padding: "6px 9px", border: "1px dashed var(--border)", borderRadius: 8 }}>
236
+ <div style={{ fontSize: 10, color: "var(--text-dim)", marginBottom: 3 }}>{ct("git.planExcluded")}</div>
237
+ {excluded.map((f) => (
238
+ <div key={f} style={{ display: "flex", alignItems: "center", gap: 6, fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--text-dim)", padding: "1.5px 0" }}>
239
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" as const, flex: 1, minWidth: 0 }}>{f}</span>
240
+ <button type="button" title={ct("git.planRestore")} onClick={() => restoreFile(f)} style={planIconBtn()}>↩</button>
241
+ </div>
242
+ ))}
243
+ </div>
244
+ ) : null}
245
+ </>
246
+ )}
247
+ </div>
248
+ <div style={{ padding: "8px 12px 10px", borderTop: "1px solid var(--border)", display: "flex", flexDirection: "column", gap: 7 }}>
249
+ {error || result && result.failedGroup != null ? (
250
+ <div style={{ fontSize: 10.5, color: "var(--text)", background: "color-mix(in srgb, var(--text) 6%, transparent)", borderRadius: 5, padding: "5px 8px", overflowWrap: "anywhere" as const }}>
251
+ ⚠ {ct("git.planStuck", { group: (result?.failedGroup ?? 0) + 1 })}{error ? ":" + error : ""}
252
+ {rolled ? " · " + ct("git.planRolledBack") : ""}
253
+ </div>
254
+ ) : null}
255
+ <div style={{ display: "flex", gap: 6 }}>
256
+ {result && result.failedGroup != null && baseHEAD ? (
257
+ <button type="button" disabled={busy} onClick={() => void rollback()} style={planSecBtn(busy)}>{ct("git.planRollback")}</button>
258
+ ) : null}
259
+ {result && result.failedGroup != null ? (
260
+ <button type="button" disabled={busy} onClick={() => execute(result.failedGroup ?? 0)} style={planSecBtn(busy)}>{ct("git.planRetry")}</button>
261
+ ) : null}
262
+ <button type="button" disabled={busy || !groups || groups.length === 0} onClick={() => execute(0)} style={{
263
+ flex: 1, height: 27, borderRadius: 7, fontSize: 11.5, fontWeight: 600,
264
+ background: "var(--accent)", color: "var(--bg)", border: "1px solid var(--accent)",
265
+ cursor: busy || !groups || groups.length === 0 ? "not-allowed" : "pointer", opacity: busy || !groups || groups.length === 0 ? 0.6 : 1,
266
+ }}>{busy ? "…" : ct("git.planExecute")}</button>
267
+ <button type="button" onClick={p.onClose} style={{ border: "1px solid var(--border)", background: "var(--bg-hover)", color: "var(--text-muted)", borderRadius: 7, fontSize: 11, padding: "0 12px", cursor: "pointer" }}>{ct("files.cancel")}</button>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ const planIconBtn = () => ({
276
+ border: "none", background: "none", color: "var(--text-dim)", cursor: "pointer", fontSize: 10.5,
277
+ padding: "1px 4px", flexShrink: 0,
278
+ } as const);
279
+ const planSecBtn = (busy: boolean) => ({
280
+ height: 27, borderRadius: 7, fontSize: 11, padding: "0 10px", cursor: busy ? "not-allowed" : "pointer",
281
+ background: "var(--bg-hover)", color: "var(--text)", border: "1px solid var(--border)", opacity: busy ? 0.6 : 1,
282
+ } as const);