@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.
- package/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/check-client.mjs +273 -0
- package/client/src/comp-i18n.ts +144 -0
- package/client/src/directory-browser.tsx +58 -0
- package/client/src/entry.tsx +82 -0
- package/client/src/folder-actions.ts +13 -0
- package/client/src/folder-browser.tsx +1568 -0
- package/client/src/folder-dock.test.mjs +241 -0
- package/client/src/folder-dock.tsx +646 -0
- package/client/src/folder-switcher.tsx +212 -0
- package/client/src/git-plan.tsx +282 -0
- package/client/src/git-toolbar.tsx +382 -0
- package/client/src/host-shim.ts +126 -0
- package/client/src/icons.tsx +135 -0
- package/client/src/jsx-runtime.ts +2 -0
- package/client/src/lib.ts +148 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/dist/aiti-folder-client.mjs +4 -0
- package/dist/entry-e6L3SQB9.js +2411 -0
- package/dist/folder-browser-dlVFbFla.js +3830 -0
- package/icon.svg +1 -0
- package/index.ts +827 -0
- package/package.json +28 -4
- package/reload-instances.mjs +60 -0
- package/server/git-plan.test.mjs +62 -0
- package/server/git-plan.ts +196 -0
- package/tools/build-client.mjs +27 -0
- package/tools/deploy.mjs +100 -0
- package/vite.config.ts +43 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-toolbar · aiti-folder 的 git 工具面(git-ops 共享引擎方案 M3,docs/aiti-git-ops-shared-plan.md §4)。
|
|
3
|
+
*
|
|
4
|
+
* - useFolderGit(root):引擎在位探测 + refs(分支/ahead-behind/脏文件)+ 订阅
|
|
5
|
+
* `aiti-git-ops:changed:*` topic(aiti-event-bus subscribe-topics 服务,帧带 repo 过滤)→
|
|
6
|
+
* 引擎写操作后本目录树徽标/工具条即时刷新(零轮询)。
|
|
7
|
+
* - GitBar:变更区段上方单行工具条——⎇ 分支(共享 BranchPanel)· ↑ahead ↓behind(SyncBar
|
|
8
|
+
* 确认流)· ✎ 手动提交(勾选文件→stage→commit)。引擎缺席/非 git 目录整条隐藏(软依赖降级)。
|
|
9
|
+
* - 全部 git 调用直指引擎 REST `/api/extensions/aiti-git-ops/git/*`(端点即引擎,folder 零服务端新增)。
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { apiUrl, extSvc, openFile } from "./host-shim";
|
|
13
|
+
import { ct } from "./comp-i18n";
|
|
14
|
+
import { createGitOpsApi } from "../../../_shared/git-ops-client/api.mjs";
|
|
15
|
+
import { BranchPanel, BranchChipBadge } from "../../../_shared/git-ops-client/branch-panel";
|
|
16
|
+
import { CommitPlanDialog } from "./git-plan";
|
|
17
|
+
|
|
18
|
+
export interface FolderGitRefs {
|
|
19
|
+
current: string | null;
|
|
20
|
+
detached: boolean;
|
|
21
|
+
aheadBehind: [number, number] | null;
|
|
22
|
+
dirtyCount: number;
|
|
23
|
+
dirtyFiles: { xy: string; path: string }[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FolderGit {
|
|
27
|
+
/** null=探测中;false=引擎缺席或非 git 目录(工具条隐藏);true=可用 */
|
|
28
|
+
avail: boolean | null;
|
|
29
|
+
refs: FolderGitRefs | null;
|
|
30
|
+
/** 引擎写操作/topic 到达后的本地刷新序号(追加进 /api/git/status effect 依赖) */
|
|
31
|
+
statusBump: number;
|
|
32
|
+
refresh: () => void;
|
|
33
|
+
/** 行级单文件操作(git-ops M4 补:双列态行钮);返回 HTTP status */
|
|
34
|
+
op: (kind: "stage" | "unstage" | "discard", path: string) => Promise<number>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useFolderGit(root: string): FolderGit {
|
|
38
|
+
const [avail, setAvail] = useState<boolean | null>(null);
|
|
39
|
+
const [refs, setRefs] = useState<FolderGitRefs | null>(null);
|
|
40
|
+
const [statusBump, setStatusBump] = useState(0);
|
|
41
|
+
// ⚠ memo:api 不稳定会让探测 effect 每渲染重跑 → 请求风暴(session-manager M2 实测教训)
|
|
42
|
+
const api = useMemo(() => createGitOpsApi({ apiUrl }), []);
|
|
43
|
+
|
|
44
|
+
const probe = useCallback(async (cwd: string) => {
|
|
45
|
+
const r = await api.refs(cwd);
|
|
46
|
+
if (r.status === 200 && r.data?.ok !== false) {
|
|
47
|
+
setAvail(true);
|
|
48
|
+
setRefs({
|
|
49
|
+
current: r.data.current ?? null,
|
|
50
|
+
detached: !!r.data.detached,
|
|
51
|
+
aheadBehind: r.data.aheadBehind ?? null,
|
|
52
|
+
dirtyCount: r.data.dirtyCount ?? 0,
|
|
53
|
+
dirtyFiles: r.data.dirtyFiles ?? [],
|
|
54
|
+
});
|
|
55
|
+
} else {
|
|
56
|
+
// 404=引擎缺席/禁用;400=非 git 目录——同样整条降级隐藏(git-ops 方案 §4 ④)
|
|
57
|
+
setAvail(false);
|
|
58
|
+
setRefs(null);
|
|
59
|
+
}
|
|
60
|
+
}, [api]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!root) { setAvail(null); setRefs(null); return; }
|
|
64
|
+
let cancelled = false;
|
|
65
|
+
setAvail(null);
|
|
66
|
+
void probe(root).catch(() => { if (!cancelled) { setAvail(false); setRefs(null); } });
|
|
67
|
+
return () => { cancelled = true; };
|
|
68
|
+
}, [root, probe]);
|
|
69
|
+
|
|
70
|
+
// topic 订阅(帧形如 { type: "aiti-git-ops:changed:<hash>", kind, summary, repo }):
|
|
71
|
+
// repo 命中当前目录才刷新;服务缺席(event-bus 未启用)→ 不订阅,靠打开/操作后刷新。
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const svc = extSvc<((h: (f: any) => void, types: string[]) => { off: () => void }) | undefined>("aiti-event-bus.subscribe-topics");
|
|
74
|
+
if (!svc) return;
|
|
75
|
+
return svc((f: any) => {
|
|
76
|
+
if (!f || typeof f.repo !== "string") return;
|
|
77
|
+
if (root && (f.repo === root || root.startsWith(f.repo + "/"))) {
|
|
78
|
+
void probe(root);
|
|
79
|
+
setStatusBump((v) => v + 1);
|
|
80
|
+
}
|
|
81
|
+
}, ["aiti-git-ops:changed:*"]).off;
|
|
82
|
+
}, [root, probe]);
|
|
83
|
+
|
|
84
|
+
const refresh = useCallback(() => {
|
|
85
|
+
if (root) void probe(root);
|
|
86
|
+
setStatusBump((v) => v + 1);
|
|
87
|
+
}, [root, probe]);
|
|
88
|
+
|
|
89
|
+
const op = useCallback(async (kind: "stage" | "unstage" | "discard", path: string): Promise<number> => {
|
|
90
|
+
const r = kind === "stage" ? await api.stage(root, [path])
|
|
91
|
+
: kind === "unstage" ? await api.unstage(root, [path])
|
|
92
|
+
: await api.discard(root, [path]);
|
|
93
|
+
void probe(root);
|
|
94
|
+
setStatusBump((v) => v + 1);
|
|
95
|
+
return r.status;
|
|
96
|
+
}, [api, root, probe]);
|
|
97
|
+
|
|
98
|
+
return { avail, refs, statusBump, refresh, op };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ── GitBar:变更区段上方单行工具条 ── */
|
|
102
|
+
|
|
103
|
+
export function GitBar(p: {
|
|
104
|
+
root: string;
|
|
105
|
+
git: FolderGit;
|
|
106
|
+
onChanged?: () => void;
|
|
107
|
+
/** 变更区段开合(dock 头的变更开关在部分形态不可达——GitBar 提供保底入口) */
|
|
108
|
+
changesOpen?: boolean;
|
|
109
|
+
onToggleChanges?: () => void;
|
|
110
|
+
}) {
|
|
111
|
+
const { git, root } = p;
|
|
112
|
+
const [panelOpen, setPanelOpen] = useState(false);
|
|
113
|
+
const [anchor, setAnchor] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
114
|
+
const [commitOpen, setCommitOpen] = useState(false);
|
|
115
|
+
const [planOpen, setPlanOpen] = useState(false);
|
|
116
|
+
const [syncBusy, setSyncBusy] = useState(false);
|
|
117
|
+
const [toast, setToast] = useState<string | null>(null);
|
|
118
|
+
const [error, setError] = useState<string | null>(null);
|
|
119
|
+
const api = useMemo(() => createGitOpsApi({ apiUrl }), []);
|
|
120
|
+
const ab = git.refs?.aheadBehind ?? null;
|
|
121
|
+
|
|
122
|
+
const runSync = useCallback(async (kind: "push" | "pull", mode?: string) => {
|
|
123
|
+
if (syncBusy) return;
|
|
124
|
+
setSyncBusy(true); setError(null);
|
|
125
|
+
try {
|
|
126
|
+
const r = kind === "push" ? await api.push(root, {}) : await api.pull(root, (mode as any) || "ff-only");
|
|
127
|
+
if (r.status !== 200) {
|
|
128
|
+
setError((r.data?.error ?? "HTTP " + r.status));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setToast(kind === "push" ? "push ✓" : "pull ✓" + (r.data?.output ? "\n" + r.data.output : ""));
|
|
132
|
+
git.refresh();
|
|
133
|
+
p.onChanged?.();
|
|
134
|
+
} finally {
|
|
135
|
+
setSyncBusy(false);
|
|
136
|
+
}
|
|
137
|
+
}, [syncBusy, api, root, git, p.onChanged]);
|
|
138
|
+
|
|
139
|
+
if (git.avail !== true || !git.refs?.current) return null;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "3px 10px 3px", borderBottom: "1px solid var(--border)", fontSize: 11 }}>
|
|
143
|
+
{/* ⎇ 分支 chip(共享 BranchPanel) */}
|
|
144
|
+
<span
|
|
145
|
+
data-folder-git-chip="1"
|
|
146
|
+
title={ct("git.branchPanelTitle")}
|
|
147
|
+
onClick={(e) => {
|
|
148
|
+
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
149
|
+
setAnchor({ left: r.left, top: r.top, right: r.right, bottom: r.bottom });
|
|
150
|
+
setPanelOpen((v) => !v);
|
|
151
|
+
}}
|
|
152
|
+
style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "1px 6px", borderRadius: 5, cursor: "pointer",
|
|
153
|
+
fontFamily: "var(--font-mono)", fontSize: 10.5, color: panelOpen ? "var(--accent)" : "var(--text)",
|
|
154
|
+
border: "1px solid " + (panelOpen ? "var(--accent)" : "var(--border)"), background: "var(--bg-hover)",
|
|
155
|
+
maxWidth: 150, overflow: "hidden", whiteSpace: "nowrap" as const, flexShrink: 1, minWidth: 0 }}
|
|
156
|
+
>
|
|
157
|
+
<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
|
|
158
|
+
<circle cx="4" cy="3.5" r="1.8" /><circle cx="4" cy="12.5" r="1.8" /><circle cx="12" cy="3.5" r="1.8" />
|
|
159
|
+
<path d="M4 5.3v5.4M12 5.3c0 3-3 3.6-6 4.1" />
|
|
160
|
+
</svg>
|
|
161
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{git.refs.current}</span>
|
|
162
|
+
</span>
|
|
163
|
+
{/* SyncBar:↑ahead ↓behind */}
|
|
164
|
+
{ab && ab[0] > 0 ? (
|
|
165
|
+
<button type="button" disabled={syncBusy} title={ct("git.pushTitle")} onClick={() => { if (window.confirm(ct("git.pushConfirm", { count: ab[0] }))) void runSync("push"); }}
|
|
166
|
+
style={syncBtnStyle()}>{ab[0]}<MiArrowUpSmall /></button>
|
|
167
|
+
) : null}
|
|
168
|
+
{ab && ab[1] > 0 ? (
|
|
169
|
+
<button type="button" disabled={syncBusy} title={ct("git.pullTitle")} onClick={() => { if (window.confirm(ct("git.pullConfirm", { count: ab[1] }))) void runSync("pull"); }}
|
|
170
|
+
style={syncBtnStyle()}><MiArrowDownSmall />{ab[1]}</button>
|
|
171
|
+
) : null}
|
|
172
|
+
{ab && ab[0] === 0 && ab[1] === 0 ? <span style={{ color: "var(--text-dim)", fontSize: 10, fontFamily: "var(--font-mono)" }}>✓</span> : null}
|
|
173
|
+
<span style={{ flex: 1 }} />
|
|
174
|
+
{/* 分组提交(M4:启发式 + 引擎逐笔执行断点/回滚) */}
|
|
175
|
+
<button type="button" title={ct("git.planTitle")} onClick={() => { git.refresh(); setPlanOpen(true); }}
|
|
176
|
+
style={{ display: "inline-flex", alignItems: "center", gap: 3, border: "none", background: "none", color: "var(--text-dim)", cursor: "pointer", fontSize: 11, padding: "2px 4px", borderRadius: 4 }}>
|
|
177
|
+
{ct("git.planShort")}
|
|
178
|
+
</button>
|
|
179
|
+
{/* 变更区段开关(保底入口) */}
|
|
180
|
+
{p.onToggleChanges ? (
|
|
181
|
+
<button type="button" title={ct("git.changesToggle")} aria-pressed={p.changesOpen}
|
|
182
|
+
onClick={p.onToggleChanges}
|
|
183
|
+
style={{ display: "inline-flex", alignItems: "center", gap: 3, border: "none", background: "none",
|
|
184
|
+
color: p.changesOpen ? "var(--accent)" : "var(--text-dim)", cursor: "pointer", fontSize: 11, padding: "2px 4px", borderRadius: 4 }}>
|
|
185
|
+
☰
|
|
186
|
+
</button>
|
|
187
|
+
) : null}
|
|
188
|
+
{/* 手动提交:先重探 refs(用户/agent 可能在挂载后改过文件),再开弹窗——列表不陈旧 */}
|
|
189
|
+
<button type="button" title={ct("git.commitTitle")} onClick={() => { git.refresh(); setCommitOpen(true); }}
|
|
190
|
+
style={{ display: "inline-flex", alignItems: "center", gap: 3, border: "none", background: "none", color: "var(--text-dim)", cursor: "pointer", fontSize: 11, padding: "2px 4px", borderRadius: 4 }}>
|
|
191
|
+
{ct("git.commitShort")}
|
|
192
|
+
</button>
|
|
193
|
+
{toast || error ? (
|
|
194
|
+
<span title={error ?? toast} onClick={() => { setToast(null); setError(null); }}
|
|
195
|
+
style={{ fontSize: 10, color: error ? "var(--text)" : "var(--text-dim)", cursor: "pointer", maxWidth: 180, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" as const, fontFamily: "var(--font-mono)" }}>
|
|
196
|
+
{error ? "⚠ " + error : toast}
|
|
197
|
+
</span>
|
|
198
|
+
) : null}
|
|
199
|
+
|
|
200
|
+
{/* 共享 BranchPanel(folder 无 worktree 面 → 面板自动隐藏该分区) */}
|
|
201
|
+
<BranchPanel
|
|
202
|
+
apiUrl={apiUrl}
|
|
203
|
+
cwd={root}
|
|
204
|
+
floatKey="folder-branch-panel"
|
|
205
|
+
locale={typeof navigator !== "undefined" ? navigator.language : "en"}
|
|
206
|
+
open={panelOpen}
|
|
207
|
+
onClose={() => setPanelOpen(false)}
|
|
208
|
+
anchor={anchor}
|
|
209
|
+
extSvc={extSvc}
|
|
210
|
+
openFile={(fp) => openFile(fp, fp.split("/").pop())}
|
|
211
|
+
onChanged={() => { git.refresh(); p.onChanged?.(); }}
|
|
212
|
+
/>
|
|
213
|
+
|
|
214
|
+
{/* 手动提交弹窗(勾选文件 → stage → commit;原 folder 案 §5.4 手动路径) */}
|
|
215
|
+
{commitOpen ? (
|
|
216
|
+
<CommitDialog
|
|
217
|
+
root={root}
|
|
218
|
+
dirtyFiles={git.refs.dirtyFiles}
|
|
219
|
+
onClose={() => setCommitOpen(false)}
|
|
220
|
+
onDone={() => { setCommitOpen(false); git.refresh(); p.onChanged?.(); }}
|
|
221
|
+
/>
|
|
222
|
+
) : null}
|
|
223
|
+
{planOpen ? (
|
|
224
|
+
<CommitPlanDialog root={root} git={git} onClose={() => setPlanOpen(false)} />
|
|
225
|
+
) : null}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const syncBtnStyle = () => ({
|
|
231
|
+
display: "inline-flex", alignItems: "center", gap: 2, border: "none", background: "none",
|
|
232
|
+
color: "var(--text-dim)", cursor: "pointer", fontSize: 10.5, padding: "2px 4px", borderRadius: 4,
|
|
233
|
+
fontFamily: "var(--font-mono)",
|
|
234
|
+
} as const);
|
|
235
|
+
|
|
236
|
+
function MiArrowUpSmall() {
|
|
237
|
+
return <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="17 11 12 6 7 11" /><line x1="12" y1="6" x2="12" y2="20" /></svg>;
|
|
238
|
+
}
|
|
239
|
+
function MiArrowDownSmall() {
|
|
240
|
+
return <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="7 13 12 18 17 13" /><line x1="12" y1="18" x2="12" y2="4" /></svg>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ── 手动提交弹窗 ── */
|
|
244
|
+
|
|
245
|
+
function CommitDialog(p: { root: string; dirtyFiles: { xy: string; path: string }[]; onClose: () => void; onDone: () => void }) {
|
|
246
|
+
const api = useMemo(() => createGitOpsApi({ apiUrl }), []);
|
|
247
|
+
const staged0 = useMemo(() => new Set(p.dirtyFiles.filter((f) => f.xy[0] !== " " && f.xy[0] !== "?").map((f) => f.path)), [p.dirtyFiles]);
|
|
248
|
+
const [checked, setChecked] = useState<Set<string>>(staged0);
|
|
249
|
+
const [message, setMessage] = useState("");
|
|
250
|
+
const [amend, setAmend] = useState(false);
|
|
251
|
+
const [busy, setBusy] = useState(false);
|
|
252
|
+
const [error, setError] = useState<string | null>(null);
|
|
253
|
+
// AI 生成提交信息(docs/aiti-folder-commit-ai-message-plan.md):服务可用性探测 + 生成状态
|
|
254
|
+
const [aiAvail, setAiAvail] = useState(false);
|
|
255
|
+
const [aiBusy, setAiBusy] = useState(false);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
let cancelled = false;
|
|
259
|
+
fetch(apiUrl("/api/extensions/aiti-folder/git-commit-ai-avail"), { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
|
|
260
|
+
.then((r) => r.json())
|
|
261
|
+
.then((d) => { if (!cancelled) setAiAvail(!!d?.avail); })
|
|
262
|
+
.catch(() => { if (!cancelled) setAiAvail(false); });
|
|
263
|
+
return () => { cancelled = true; };
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
266
|
+
const genMessage = useCallback(async () => {
|
|
267
|
+
if (aiBusy || checked.size === 0) return;
|
|
268
|
+
if (message.trim() && !window.confirm(ct("git.aiMessageOverwrite"))) return;
|
|
269
|
+
setAiBusy(true); setError(null);
|
|
270
|
+
try {
|
|
271
|
+
// basePath 从 apiUrl 推导(host-shim 前缀 /dev 等);origin 本页已知(同 CommitPlanDialog.aiRefine)
|
|
272
|
+
const p1 = apiUrl("/api/agent/new");
|
|
273
|
+
const basePath = p1.slice(0, Math.max(0, p1.indexOf("/api/")));
|
|
274
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/git-commit-ai-message"), {
|
|
275
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
276
|
+
body: JSON.stringify({ cwd: p.root, origin: location.origin, basePath, files: [...checked], amend }),
|
|
277
|
+
}).then((x) => x.json()).catch(() => null);
|
|
278
|
+
if (!r?.ok) { setError(r?.error || ct("i18n.networkError")); return; }
|
|
279
|
+
setMessage(String(r.message || ""));
|
|
280
|
+
} finally {
|
|
281
|
+
setAiBusy(false);
|
|
282
|
+
}
|
|
283
|
+
}, [aiBusy, checked, message, amend, p.root]);
|
|
284
|
+
|
|
285
|
+
const submit = useCallback(async () => {
|
|
286
|
+
if (busy || (!message.trim() && !amend)) return;
|
|
287
|
+
setBusy(true); setError(null);
|
|
288
|
+
try {
|
|
289
|
+
// 勾选且未在暂存区的 → add;取消勾选但已在暂存区的 → restore --staged
|
|
290
|
+
const toStage = [...checked].filter((f) => !staged0.has(f));
|
|
291
|
+
const toUnstage = [...staged0].filter((f) => !checked.has(f));
|
|
292
|
+
if (toStage.length > 0) {
|
|
293
|
+
const r = await api.stage(p.root, toStage);
|
|
294
|
+
if (r.status !== 200) { setError(r.data?.error ?? "HTTP " + r.status); return; }
|
|
295
|
+
}
|
|
296
|
+
if (toUnstage.length > 0) {
|
|
297
|
+
const r = await api.unstage(p.root, toUnstage);
|
|
298
|
+
if (r.status !== 200) { setError(r.data?.error ?? "HTTP " + r.status); return; }
|
|
299
|
+
}
|
|
300
|
+
const r = await api.commit(p.root, message.trim() || undefined, amend);
|
|
301
|
+
if (r.status !== 200) { setError(r.data?.error ?? "HTTP " + r.status); return; }
|
|
302
|
+
p.onDone();
|
|
303
|
+
} finally {
|
|
304
|
+
setBusy(false);
|
|
305
|
+
}
|
|
306
|
+
}, [busy, message, amend, checked, staged0, api, p.root, p.onDone]);
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<div onClick={(e) => { if (e.target === e.currentTarget && !busy) p.onClose(); }}
|
|
310
|
+
style={{ position: "fixed", inset: 0, zIndex: 900, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
311
|
+
{/* data-ext-float:aiti-float 接管拖拽/缩放/持久化;autoh=高度随内容(textarea 可拉、文件列表不定长);
|
|
312
|
+
full=小屏(≤640px)默认铺满视口(大屏仍浮窗可缩放移动)——契约 docs/aiti-web-ext-float-dialogs.md */}
|
|
313
|
+
<div data-ext-float="1" data-ext-float-full="1" data-ext-float-autoh="1"
|
|
314
|
+
data-ext-float-key="aiti-folder.commit" data-ext-float-w={380}
|
|
315
|
+
onClick={(e) => e.stopPropagation()}
|
|
316
|
+
style={{ width: 380, maxWidth: "92vw", maxHeight: "80vh", 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 }}>
|
|
317
|
+
<div style={{ display: "flex", alignItems: "center", padding: "10px 12px", borderBottom: "1px solid var(--border)", fontWeight: 600 }}>
|
|
318
|
+
{ct("git.commitTitle")}
|
|
319
|
+
<span style={{ flex: 1 }} />
|
|
320
|
+
<button type="button" onClick={p.onClose} aria-label={ct("i18n.close")} title={ct("i18n.close")}
|
|
321
|
+
style={{ border: "none", background: "none", color: "var(--text-dim)", cursor: "pointer", fontSize: 12, padding: "2px 4px" }}>✕</button>
|
|
322
|
+
</div>
|
|
323
|
+
<div style={{ flex: "1 1 auto", minHeight: 0, overflowY: "auto", padding: "6px 12px" }}>
|
|
324
|
+
<div style={{ display: "flex", gap: 6, margin: "4px 0" }}>
|
|
325
|
+
<button type="button" onClick={() => setChecked(new Set(p.dirtyFiles.map((f) => f.path)))} style={miniGhost()}>{ct("git.stageAll")}</button>
|
|
326
|
+
<button type="button" onClick={() => setChecked(new Set(staged0))} style={miniGhost()}>{ct("git.stageNone")}</button>
|
|
327
|
+
<span style={{ flex: 1 }} />
|
|
328
|
+
<span style={{ fontSize: 10.5, color: "var(--text-dim)", alignSelf: "center", fontFamily: "var(--font-mono)" }}>
|
|
329
|
+
{ct("git.stagedCount", { count: checked.size })}
|
|
330
|
+
</span>
|
|
331
|
+
</div>
|
|
332
|
+
{p.dirtyFiles.map((f) => {
|
|
333
|
+
const on = checked.has(f.path);
|
|
334
|
+
return (
|
|
335
|
+
<label key={f.path} style={{ display: "flex", alignItems: "center", gap: 7, padding: "2.5px 0", cursor: "pointer", fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--text-muted)" }}>
|
|
336
|
+
<input type="checkbox" checked={on} onChange={(e) => setChecked((prev) => {
|
|
337
|
+
const next = new Set(prev);
|
|
338
|
+
if (e.target.checked) next.add(f.path); else next.delete(f.path);
|
|
339
|
+
return next;
|
|
340
|
+
})} style={{ accentColor: "var(--accent)", margin: 0, flexShrink: 0 }} />
|
|
341
|
+
<span style={{ width: 20, flexShrink: 0, color: "var(--text)" }}>{f.xy}</span>
|
|
342
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" as const }}>{f.path}</span>
|
|
343
|
+
</label>
|
|
344
|
+
);
|
|
345
|
+
})}
|
|
346
|
+
</div>
|
|
347
|
+
<div style={{ padding: "8px 12px 10px", borderTop: "1px solid var(--border)", display: "flex", flexDirection: "column", gap: 7 }}>
|
|
348
|
+
<textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder={ct("git.commitPlaceholder")} rows={2}
|
|
349
|
+
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void submit(); } }}
|
|
350
|
+
style={{ width: "100%", boxSizing: "border-box" as const, resize: "vertical" as const, minHeight: 44, background: "var(--bg-hover)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 11.5, padding: "6px 8px", outline: "none", fontFamily: "inherit" }} />
|
|
351
|
+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
352
|
+
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: "var(--text-muted)", cursor: "pointer" }}>
|
|
353
|
+
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} style={{ accentColor: "var(--accent)", margin: 0 }} />
|
|
354
|
+
{ct("git.amend")}
|
|
355
|
+
</label>
|
|
356
|
+
<span style={{ flex: 1 }} />
|
|
357
|
+
{aiAvail ? (
|
|
358
|
+
<button type="button" disabled={aiBusy || checked.size === 0} onClick={() => void genMessage()} title={ct("git.aiMessageTitle")}
|
|
359
|
+
style={{ height: 20, padding: "0 8px", borderRadius: 5, fontSize: 10.5, cursor: aiBusy || checked.size === 0 ? "not-allowed" : "pointer",
|
|
360
|
+
background: "var(--bg-hover)", color: "var(--text-muted)", border: "1px solid var(--border)", opacity: aiBusy || checked.size === 0 ? 0.6 : 1 }}>
|
|
361
|
+
{aiBusy ? ct("git.aiMessageBusy") : ct("git.aiMessage")}
|
|
362
|
+
</button>
|
|
363
|
+
) : null}
|
|
364
|
+
</div>
|
|
365
|
+
{error ? <div style={{ fontSize: 10.5, color: "var(--text)", background: "color-mix(in srgb, var(--text) 6%, transparent)", borderRadius: 5, padding: "4px 8px", overflowWrap: "anywhere" as const }}>⚠ {error}</div> : null}
|
|
366
|
+
<div style={{ display: "flex", gap: 6 }}>
|
|
367
|
+
<button type="button" disabled={busy || (!message.trim() && !amend) || checked.size === 0} onClick={() => void submit()} style={{
|
|
368
|
+
flex: 1, height: 27, borderRadius: 7, fontSize: 11.5, fontWeight: 600, cursor: busy ? "not-allowed" : "pointer",
|
|
369
|
+
background: "var(--accent)", color: "var(--bg)", border: "1px solid var(--accent)", opacity: busy || (!message.trim() && !amend) || checked.size === 0 ? 0.6 : 1,
|
|
370
|
+
}}>{busy ? "…" : ct("git.commitBtn")}</button>
|
|
371
|
+
<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>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const miniGhost = () => ({
|
|
380
|
+
height: 22, padding: "0 8px", borderRadius: 5, fontSize: 10.5, cursor: "pointer",
|
|
381
|
+
background: "var(--bg-hover)", color: "var(--text-muted)", border: "1px solid var(--border)",
|
|
382
|
+
} as const);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host-shim —— JSX client bundle 的宿主桥(共享模板通用版)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:浏览器不认识 JSX,扩展 client 要写 JSX 必须经过 esbuild 预编译
|
|
5
|
+
* (vite lib mode)。esbuild 的 JSX 自动运行时(jsx: "automatic")会生成
|
|
6
|
+
* `jsx(type, props)` 调用,默认指向 React。但 pi-web 的扩展 client 不是 React——
|
|
7
|
+
* 宿主经 register(ctx) 传入的是自己的渲染原语(h / useState / useEffect / apiUrl…)。
|
|
8
|
+
*
|
|
9
|
+
* 所以这里用 vite 的 resolve.alias 把 react / react/jsx-runtime 重定向到本 shim:
|
|
10
|
+
* 组件里 import react 的 hooks → 本 shim 的 createElement/useState/… → 委托 ctx。
|
|
11
|
+
* 产物里零 React 引用,宿主保持单渲染实例,npm 生态组件(只要用标准 react hooks)
|
|
12
|
+
* 也能直接打包进来用。
|
|
13
|
+
*
|
|
14
|
+
* 使用:entry.tsx 的 register(ctx) 里第一件事调 `__setCtx(ctx)`(任何 JSX 渲染前)。
|
|
15
|
+
* 需要的字段(apiUrl/t/useRef/useMemo…)按需在这里加一行委托即可。
|
|
16
|
+
*/
|
|
17
|
+
import { lastSeg } from "./lib";
|
|
18
|
+
|
|
19
|
+
// 模块级 ctx(entry.tsx 的 register 时设置;组件渲染时读取)
|
|
20
|
+
let _ctx: Record<string, unknown> | null = null;
|
|
21
|
+
export function __setCtx(ctx: Record<string, unknown>): void { _ctx = ctx; }
|
|
22
|
+
|
|
23
|
+
/** DI 方案 P2:跨扩展客户端服务读取(ctx.getExtService 的 shim 封装;提供方懒加载/缺席 → undefined,消费方必须降级) */
|
|
24
|
+
export function extSvc<T = unknown>(key: string): T | undefined {
|
|
25
|
+
return (_ctx as any)?.getExtService?.(key) as T | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** DI 方案 P2:注册跨扩展客户端服务(ctx.registerExtService 的 shim 封装;返回 disposer,旧宿主返回 null) */
|
|
29
|
+
export function regSvc(key: string, impl: unknown): (() => void) | null {
|
|
30
|
+
const c = _ctx as any;
|
|
31
|
+
return typeof c?.registerExtService === "function" ? c.registerExtService(key, impl) : null;
|
|
32
|
+
}
|
|
33
|
+
function c<T = (...args: never[]) => unknown>(name: string): T {
|
|
34
|
+
return (_ctx?.[name] as T) ?? (() => undefined as never);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------- react 兼容导出(esbuild alias react → 本 shim) ----------
|
|
38
|
+
// React 的 Fragment 类型是 Symbol.for("react.fragment")——传给宿主 h 即生成真 Fragment。
|
|
39
|
+
export const Fragment = (typeof Symbol !== "undefined" && Symbol.for) ? Symbol.for("react.fragment") : 0xacb;
|
|
40
|
+
export function createElement(...args: unknown[]): unknown {
|
|
41
|
+
return c("h").call(null, ...args);
|
|
42
|
+
}
|
|
43
|
+
// 自动运行时 jsx(type, props, key)——key 是独立第三参;宿主 h() = React.createElement
|
|
44
|
+
// 的第三参是 **children**。直接透传会把 key 当子元素(元素 key 丢失 + key 值覆盖
|
|
45
|
+
// props.children 成为真实内容)。因此必须把 key 合并回 props。
|
|
46
|
+
function mergeKey(args: unknown[]): unknown[] {
|
|
47
|
+
const [type, props, key, ...rest] = args;
|
|
48
|
+
if (props && typeof props === "object") {
|
|
49
|
+
// key 一律合并进 props,createElement 恒收 (type, config) 两参:
|
|
50
|
+
// 有 key → config.key 由 React 读为元素 key;key 为 undefined/null → 也必须剥掉第三参——
|
|
51
|
+
// 否则 createElement(type, config, undefined) 按 legacy 语义(第三参=children)用 undefined
|
|
52
|
+
// 覆盖 props.children,元素内容整体丢失(实证:aiti-editor ⋯ 菜单 key=undefined 的项全部空白)。
|
|
53
|
+
const merged: Record<string, unknown> = { ...(props as Record<string, unknown>) };
|
|
54
|
+
if (key !== undefined && key !== null) merged.key = key;
|
|
55
|
+
return [type, merged, ...rest];
|
|
56
|
+
}
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
export const jsx = (...args: unknown[]) => createElement(...mergeKey(args));
|
|
60
|
+
export const jsxs = (...args: unknown[]) => {
|
|
61
|
+
// React 自动运行时语义:jsxs(静态多子元素)创建时会对每个子元素调用
|
|
62
|
+
// validateChildKeys → 标记 _store.validated = 1("已校验,勿再告警")。宿主 h()
|
|
63
|
+
// 是 React.createElement,但它只创建元素、不做这个标记——不补的话,
|
|
64
|
+
// `<div><A/><B/></div>` 这类“静态兄弟元素”(JSX 里无需写 key,React 自动运行时
|
|
65
|
+
// 本应抑制该警告)会被 React 误报 “Each child in a list should have a unique key”
|
|
66
|
+
// (dev-only,按 owner 去重一次)。值必须是 1(2 是另一特殊标记,仍会告警)。
|
|
67
|
+
const props = (args as [unknown, { children?: unknown } | undefined, ...unknown[]])[1];
|
|
68
|
+
const children = props?.children;
|
|
69
|
+
if (Array.isArray(children)) {
|
|
70
|
+
for (const c of children) {
|
|
71
|
+
if (c && typeof c === "object" && (c as { _store?: { validated?: number } })._store) {
|
|
72
|
+
(c as { _store: { validated: number } })._store.validated = 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return createElement(...mergeKey(args));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ---------- React hooks 委托(按需增删) ----------
|
|
80
|
+
export const useState = <T>(init: T | (() => T)) => c<(...a: unknown[]) => [T, (v: T | ((prev: T) => T)) => void]>("useState")(init);
|
|
81
|
+
export const useEffect = (fn: () => unknown, deps?: unknown[]) => c("useEffect")(fn, deps);
|
|
82
|
+
export const useLayoutEffect = (fn: () => unknown, deps?: unknown[]) => c("useLayoutEffect")(fn, deps);
|
|
83
|
+
export const useRef = <T>(v: T) => c<(...a: unknown[]) => { current: T }>("useRef")(v);
|
|
84
|
+
export const useCallback = <T extends (...args: never[]) => unknown>(fn: T, deps?: unknown[]) => c<(...a: unknown[]) => T>("useCallback")(fn, deps);
|
|
85
|
+
export const useMemo = <T>(fn: () => T, deps?: unknown[]) => c<(...a: unknown[]) => T>("useMemo")(fn, deps);
|
|
86
|
+
|
|
87
|
+
// ---------- 宿主工具委托(按需增删) ----------
|
|
88
|
+
/** apiUrl(p):把相对路径拼成当前 basePath 下的完整 API URL(多实例 /dev 前缀安全)。 */
|
|
89
|
+
export function apiUrl(p: string): string {
|
|
90
|
+
const fn = _ctx?.apiUrl as ((p: string) => string) | undefined;
|
|
91
|
+
return typeof fn === "function" ? fn(p) : p;
|
|
92
|
+
}
|
|
93
|
+
/** t(key, params?):扩展 i18n 翻译(index.ts ui.i18n 里的词条)。 */
|
|
94
|
+
export function t(key: string, params?: Record<string, unknown>): string {
|
|
95
|
+
const fn = _ctx?.t as ((key: string, params?: Record<string, unknown>) => string) | undefined;
|
|
96
|
+
return typeof fn === "function" ? fn(key, params) : key;
|
|
97
|
+
}
|
|
98
|
+
/** getFileTabs():当前打开 tab 清单(数组序=打开序;批量关闭原语;宿主未提供时返回空数组)。 */
|
|
99
|
+
export function getFileTabs(): { id: string; filePath: string; kind?: "file" | "folder"; pinned?: boolean }[] {
|
|
100
|
+
const fn = _ctx?.getFileTabs as (() => unknown[]) | undefined;
|
|
101
|
+
return typeof fn === "function" ? ((fn() || []) as ReturnType<typeof getFileTabs>) : [];
|
|
102
|
+
}
|
|
103
|
+
/** closeFileTabs(ids):关闭指定 tab(仅状态移除+激活位重排;钉住保护由调用方负责)。 */
|
|
104
|
+
export function closeFileTabs(ids: string[]): void {
|
|
105
|
+
const fn = _ctx?.closeFileTabs as ((ids: string[]) => void) | undefined;
|
|
106
|
+
if (typeof fn === "function") fn(ids);
|
|
107
|
+
}
|
|
108
|
+
/** openFile(path, fileName?):宿主打开文件标签页(sidebar 文件 dock 区块回调缺省用)。
|
|
109
|
+
* fileName 兜底 lastSeg(path):宿主 handleOpenFile 的 fileName 为 tab.label 原料,缺省会
|
|
110
|
+
* 致 FileIcons.getFileIcon(undefined) 崩溃(2026-09-15 dev 实捩:文件夹树点文件白屏)。 */
|
|
111
|
+
export function openFile(path: string, fileName?: string): void {
|
|
112
|
+
const fn = _ctx?.openFile as ((p: string, f?: string) => void) | undefined;
|
|
113
|
+
if (typeof fn === "function") fn(path, fileName ?? lastSeg(path));
|
|
114
|
+
}
|
|
115
|
+
/** openFolder(path, folderName?):宿主打开文件夹标签页(sidebar 文件 dock 区块回调缺省用)。 */
|
|
116
|
+
export function openFolder(path: string, folderName?: string): void {
|
|
117
|
+
const fn = _ctx?.openFolder as ((p: string, f?: string) => void) | undefined;
|
|
118
|
+
if (typeof fn === "function") fn(path, folderName ?? lastSeg(path));
|
|
119
|
+
}
|
|
120
|
+
/** getFeatures():本扩展 feature 状态快照(boolean/string;extman 改动即时反映)。
|
|
121
|
+
* 注意:folder-dock 被双 bundle 内联——配额强制只在 session-manager 侧发生,
|
|
122
|
+
* 故 sidebar.folder-quota 声明在 session-manager(getFeatures 只读声明方自己的)。 */
|
|
123
|
+
export function getFeatures(): Record<string, boolean | string> {
|
|
124
|
+
const fn = _ctx?.getFeatures as (() => Record<string, boolean | string>) | undefined;
|
|
125
|
+
return typeof fn === "function" ? (fn() || {}) : {};
|
|
126
|
+
}
|