@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.
- 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,1568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-folder · 目录浏览器(fileViewer folder 接管)——自手写版迁移,一比一
|
|
3
|
+
*
|
|
4
|
+
* 完整对等原核心 FolderViewer:树行内展开、文件行→开文件、子文件夹行「打开」→开新 tab、
|
|
5
|
+
* @mention、git 状态徽标 + 目录「含变更」点。工具栏:+ 菜单(新建/上传)、变更面板、刷新。
|
|
6
|
+
* 开放点:window.__aitiFolder.registerAction(其他扩展注册目录级工具条按钮)。
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
9
|
+
import { apiUrl, t, getFileTabs, closeFileTabs, extSvc } from "./host-shim";
|
|
10
|
+
import { useDockCommands, useDockHeaderState, useDockHeaderMenuItems, writeBlockCollapsed, usePresent, closePanel } from "../../../_shared/ext-float-dock";
|
|
11
|
+
import type { DockMenuItem } from "../../../_shared/ext-float-dock";
|
|
12
|
+
import { lastSeg, normSlash, dirOf, relTo, relFromBase, joinPath, buildGitByPath, buildChangedDirs, fetchEntries, peekEntries, fetchList, peekList, recentProjects, displayCwd, buildSearchTree, type SearchTreeNode } from "./lib";
|
|
13
|
+
import { GitBadge, FolderIcon, FileIcon, CaretIcon, CheckIcon, MiRefresh, MiExternal, MiPencil, MiMove, MiAt, MiInfo, MiDownload, MiUpload, MiTrash, MiFilePlus, MiFolderPlus, MiCopy, MiCopyTo, MiArrowUp, MiSearch, MiPin, MiX, MiCloseLeft, MiCloseRight, MiCloseAll, PlusIcon, TerminalIcon, ChangesIcon } from "./icons";
|
|
14
|
+
import { EXT_LANG, LANG_COLOR } from "./lib";
|
|
15
|
+
import { FolderSwitcher } from "./folder-switcher";
|
|
16
|
+
import { isFolderPinned, toggleFolderPin, folderItemKey } from "./folder-dock";
|
|
17
|
+
import { DirectoryBrowser } from "./directory-browser";
|
|
18
|
+
import { openViewerWindow } from "../../../_shared/open-viewer-window";
|
|
19
|
+
import { clampToViewport, fixedOrigin } from "../../../_shared/popup-anchor";
|
|
20
|
+
import { buildAtText, openNewSessionWithExtSvc } from "../../../_shared/new-session-cwd";
|
|
21
|
+
import { useFolderGit, GitBar } from "./git-toolbar";
|
|
22
|
+
import { ct } from "./comp-i18n";
|
|
23
|
+
|
|
24
|
+
// DI 方案 P2:git-review 打开/聚焦(services 通道优先,旧全局兜底)
|
|
25
|
+
const grOpen = () => extSvc<(o?: any) => void>("aiti-git-review.open") || (window as any).__aitiGitReviewOpen;
|
|
26
|
+
const grWork = () => extSvc<() => void>("aiti-git-review.work") || (window as any).__aitiGitReviewWork;
|
|
27
|
+
|
|
28
|
+
// ── 存活缓存:切走再切回不丢状态(宿主切 tab 会卸载 viewer,这里快照 + TTL 恢复)──
|
|
29
|
+
// 展开状态按根目录存快照;存活期内切回秒开(目录列表另有 ENTRIES_TTL 缓存),过期自动失效。
|
|
30
|
+
export const FOLDER_STATE_TTL = 10 * 60 * 1000; // 10 分钟存活期
|
|
31
|
+
const expandedCache = new Map<string, { expanded: string[]; ts: number }>();
|
|
32
|
+
function saveExpandedSnapshot(rootPath: string, expanded: Set<string>) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [k, v] of expandedCache) if (now - v.ts > FOLDER_STATE_TTL) expandedCache.delete(k);
|
|
35
|
+
if (expanded.size === 0) return; // 空快照不覆盖旧记录(切根目录重置时误伤)
|
|
36
|
+
expandedCache.set(normSlash(rootPath)!, { expanded: [...expanded], ts: now });
|
|
37
|
+
}
|
|
38
|
+
function loadExpandedSnapshot(rootPath?: string): Set<string> | null {
|
|
39
|
+
if (!rootPath) return null;
|
|
40
|
+
const hit = expandedCache.get(normSlash(rootPath)!);
|
|
41
|
+
if (!hit || Date.now() - hit.ts > FOLDER_STATE_TTL) return null;
|
|
42
|
+
return new Set(hit.expanded);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── tab 窗口管理(钉住/关闭族,对齐 aiti-editor ⋯ 菜单 5 条;作用域=folder tab,文件 tab 归 aiti-editor 管)──
|
|
46
|
+
// 钉住定义统一 = 区块清单 pin(localStorage pi:sidebar-dock:folders 条目 pin 标记)——
|
|
47
|
+
// 左栏区块 / 右栏 dock tab / 浮窗三面同状态同展示(一个定义一个展示,用户拍板);
|
|
48
|
+
// 旧式独立 fileViewer folder tab(不进清单)不再有独立内存钉住。
|
|
49
|
+
const folderTabId = (p: string) => "folder:" + p;
|
|
50
|
+
|
|
51
|
+
// 文件行图标:语言色点(无匹配扩展名时回退通用文件图标)
|
|
52
|
+
function NodeIcon({ name }: { name: string }) {
|
|
53
|
+
const ext = (lastSeg(name)!.split(".").pop() || "").toLowerCase();
|
|
54
|
+
const lang = EXT_LANG[ext]; const c = lang ? LANG_COLOR[lang] : null;
|
|
55
|
+
return c
|
|
56
|
+
? <span style={{ width:6, height:6, borderRadius:2, background:c, flexShrink:0, display:"inline-block" }} />
|
|
57
|
+
: <FileIcon />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type TFn = (key: string, params?: Record<string, unknown>) => string;
|
|
61
|
+
|
|
62
|
+
// ── 工具栏图标按钮(同款 sidebar:变更/上传/刷新)──
|
|
63
|
+
function TBtn(o: { key?: string; onClick?: (e: any) => void; disabled?: boolean; title?: string; pressed?: boolean; bg?: string; color?: string; children: any }) {
|
|
64
|
+
return (
|
|
65
|
+
<button key={o.key} type="button" onClick={o.onClick} disabled={o.disabled} title={o.title} aria-pressed={o.pressed?"true":undefined}
|
|
66
|
+
style={{ width:28, height:24, padding:0, display:"flex", alignItems:"center", justifyContent:"center", flexShrink:0, border:"none", background:o.bg||"transparent", color:o.color||"var(--text-dim)", cursor:o.disabled?"default":"pointer", borderRadius:4, transition:"color 0.12s, background 0.12s" }}>
|
|
67
|
+
{o.children}
|
|
68
|
+
</button>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// + 菜单项(下拉通用)
|
|
73
|
+
function menuItem(icon: any, label: any, onClick: () => void, key?: string) {
|
|
74
|
+
return <button key={key} type="button" onClick={onClick} style={{ display:"flex", alignItems:"center", gap:7, width:"100%", padding:"5px 10px", border:"none", background:"transparent", color:"var(--text,#222)", cursor:"pointer", textAlign:"left", fontSize:12, borderRadius:6, whiteSpace:"nowrap" }}>{icon ? <span style={{ display:"inline-flex", flexShrink:0, width:13, justifyContent:"center" }}>{icon}</span> : null}{label}</button>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 菜单分区:分隔线 + 小节标题(10px 淡色,置于每组首项上方;行菜单/根菜单通用)
|
|
78
|
+
function menuSep(key: string) {
|
|
79
|
+
return <div key={key} style={{ height:1, background:"var(--border,#eee)", margin:"2px 4px" }} />;
|
|
80
|
+
}
|
|
81
|
+
// ── 关闭图标排(2026-09-16 关闭族收编:左/自己/右/全部一排图标,markup 对齐 aiti-editor
|
|
82
|
+
// FileToolbar ⋯ 菜单的 closeIconRow——名称左 + 图标右对齐,hover tooltip 兜底)──
|
|
83
|
+
function FolderCloseIconRow(props: { left?: () => void; self?: () => void; right?: () => void; all?: () => void; closeTab: () => void; batch: (scope: "left"|"right"|"all") => void; t: TFn }) {
|
|
84
|
+
const { t } = props;
|
|
85
|
+
const btn = (title: string, icon: any, fn: () => void, key: string) => (
|
|
86
|
+
<button key={key} type="button" title={title} aria-label={title}
|
|
87
|
+
onClick={fn}
|
|
88
|
+
onMouseEnter={(e:any)=>{ e.currentTarget.style.background="var(--bg-hover,#f3f4f6)"; }}
|
|
89
|
+
onMouseLeave={(e:any)=>{ e.currentTarget.style.background="transparent"; }}
|
|
90
|
+
style={{ display:"inline-flex", alignItems:"center", justifyContent:"center", width:28, height:28, padding:0, border:"none", borderRadius:6, background:"transparent", color:"var(--text-muted,#777)", cursor:"pointer", flexShrink:0 }}>{icon}</button>
|
|
91
|
+
);
|
|
92
|
+
return (
|
|
93
|
+
<div data-ext-folder-close-row="1" style={{ display:"flex", alignItems:"center", justifyContent:"space-between", gap:16, padding:"5px 10px" }}>
|
|
94
|
+
<span style={{ fontSize:12, color:"var(--text-dim,#888)", flexShrink:0, userSelect:"none" }}>{t("i18n.close")}</span>
|
|
95
|
+
<div style={{ display:"flex", gap:6, marginLeft:"auto", flexShrink:0 }}>
|
|
96
|
+
{btn(t("files.closeLeft"), <MiCloseLeft size={16} />, () => props.batch("left"), "cl")}
|
|
97
|
+
{btn(t("i18n.close"), <MiX size={16} />, props.closeTab, "ct")}
|
|
98
|
+
{btn(t("files.closeRight"), <MiCloseRight size={16} />, () => props.batch("right"), "cr")}
|
|
99
|
+
{btn(t("files.closeAll"), <MiCloseAll size={16} />, () => props.batch("all"), "ca")}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
function menuHead(label: string, key: string) {
|
|
105
|
+
return <div key={key} style={{ fontSize:10, color:"var(--text-dim,#999)", fontWeight:600, padding:"3px 6px 1px", letterSpacing:0.4, userSelect:"none", whiteSpace:"nowrap" }}>{label}</div>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 剪贴板写文本:clipboard API 优先,execCommand 兜底(非安全上下文)
|
|
109
|
+
async function copyTextToClipboard(text: string): Promise<void> {
|
|
110
|
+
try {
|
|
111
|
+
if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; }
|
|
112
|
+
} catch { /* 落 execCommand 兜底 */ }
|
|
113
|
+
const ta = document.createElement("textarea");
|
|
114
|
+
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
|
115
|
+
document.body.appendChild(ta); ta.focus(); ta.select();
|
|
116
|
+
try { document.execCommand("copy"); } finally { ta.remove(); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 正文模式 snippet 高亮:doc-center 返回的 <mark>…</mark> 切分渲染为高亮 span,其余走 React 文本转义(零 innerHTML)──
|
|
120
|
+
function MarkedText({ text }: { text: string }) {
|
|
121
|
+
const parts: any[] = [];
|
|
122
|
+
const re = /<mark>([\s\S]*?)<\/mark>/g;
|
|
123
|
+
let last = 0, m: RegExpExecArray | null, k = 0;
|
|
124
|
+
while ((m = re.exec(text)) !== null) {
|
|
125
|
+
if (m.index > last) parts.push(text.slice(last, m.index));
|
|
126
|
+
parts.push(<span key={k++} style={{ background:"rgba(79,142,247,0.22)", borderRadius:2 }}>{m[1]}</span>);
|
|
127
|
+
last = m.index + m[0].length;
|
|
128
|
+
}
|
|
129
|
+
if (last < text.length) parts.push(text.slice(last));
|
|
130
|
+
return <>{parts}</>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── 文件/文件夹信息弹层(行菜单「文件信息」):现拉 entry-info,展示名称/类型/大小/修改时间/条目数 ──
|
|
134
|
+
// 所在目录可点击:onOpenFolder 存在时点击打开对应目录(新开 folder tab)
|
|
135
|
+
function EntryInfoModal(props: { path: string; onClose: () => void; t: TFn; onOpenFolder?: (p: string, name?: string) => void }) {
|
|
136
|
+
const { path, onClose, t: tt, onOpenFolder } = props;
|
|
137
|
+
const [info, setInfo] = useState<any>(null);
|
|
138
|
+
const [err, setErr] = useState<string | null>(null);
|
|
139
|
+
useEffect(() => { let c = false;
|
|
140
|
+
fetch(apiUrl("/api/extensions/aiti-folder/entry-info"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ path }) })
|
|
141
|
+
.then((r) => r.json()).then((d) => { if (!c) { if (d && d.ok) setInfo(d); else setErr((d && d.error) || "HTTP error"); } })
|
|
142
|
+
.catch((e) => { if (!c) setErr(String(e)); });
|
|
143
|
+
return () => { c = true; };
|
|
144
|
+
}, [path]);
|
|
145
|
+
const fmtSize = (n: any) => { if (n == null) return "—"; if (n < 1024) return n + " B"; const u = ["KB", "MB", "GB", "TB"]; let v = n / 1024, i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return v.toFixed(v >= 100 ? 0 : 1) + " " + u[i]; };
|
|
146
|
+
const parentDir = path.slice(0, path.lastIndexOf("/")) || "/";
|
|
147
|
+
const row = (k: string, v: any, mono?: boolean) => (
|
|
148
|
+
<div key={k} style={{ display:"flex", gap:10, alignItems:"baseline" }}>
|
|
149
|
+
<span style={{ width:64, flexShrink:0, color:"var(--text-dim,#888)" }}>{k}</span>
|
|
150
|
+
<span style={{ flex:1, minWidth:0, wordBreak:"break-all", fontFamily: mono ? "var(--font-mono,ui-monospace,monospace)" : undefined }}>{v}</span>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
const rowDir = () => (
|
|
154
|
+
<div key="dir" style={{ display:"flex", gap:10, alignItems:"baseline" }}>
|
|
155
|
+
<span style={{ width:64, flexShrink:0, color:"var(--text-dim,#888)" }}>{tt("files.infoParent")}</span>
|
|
156
|
+
{onOpenFolder ? (
|
|
157
|
+
<button type="button" title={tt("files.infoOpenParent")} onClick={()=>{ onClose(); onOpenFolder(parentDir, lastSeg(parentDir) || undefined); }}
|
|
158
|
+
style={{ flex:1, minWidth:0, wordBreak:"break-all", textAlign:"left", padding:0, border:"none", background:"transparent", cursor:"pointer",
|
|
159
|
+
color:"var(--accent,#4f8ef7)", fontFamily:"var(--font-mono,ui-monospace,monospace)", textDecoration:"underline", textDecorationStyle:"dotted", textUnderlineOffset:3 }}>{parentDir}</button>
|
|
160
|
+
) : (
|
|
161
|
+
<span style={{ flex:1, minWidth:0, wordBreak:"break-all", fontFamily:"var(--font-mono,ui-monospace,monospace)" }}>{parentDir}</span>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
return (
|
|
166
|
+
<div onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} style={{ position:"fixed", inset:0, zIndex:500, background:"rgba(0,0,0,0.35)", display:"flex", alignItems:"center", justifyContent:"center" }}>
|
|
167
|
+
<div style={{ width:400, maxWidth:"92vw", padding:"16px 18px", borderRadius:12, background:"var(--bg,#fff)", border:"1px solid var(--border,#ddd)", boxShadow:"0 12px 40px rgba(0,0,0,0.25)", fontSize:13 }}>
|
|
168
|
+
<div style={{ fontWeight:600, fontSize:14, marginBottom:10 }}>{tt("files.entryInfo")}</div>
|
|
169
|
+
{err ? <div style={{ color:"var(--danger, #f87171)", fontSize:12 }}>{err}</div>
|
|
170
|
+
: !info ? <div style={{ color:"var(--text-dim,#888)", fontSize:12 }}>…</div>
|
|
171
|
+
: (
|
|
172
|
+
<div style={{ display:"flex", flexDirection:"column", gap:7, fontSize:12 }}>
|
|
173
|
+
{row(tt("files.infoName"), info.name, true)}
|
|
174
|
+
{row(tt("files.infoType"), info.isDir ? tt("files.infoTypeDir") : tt("files.infoTypeFile"))}
|
|
175
|
+
{row(tt("files.infoSize"), fmtSize(info.size))}
|
|
176
|
+
{row(tt("files.infoMtime"), info.mtimeMs ? new Date(info.mtimeMs).toLocaleString() : "—")}
|
|
177
|
+
{rowDir()}
|
|
178
|
+
{info.isDir && info.items != null ? row(tt("files.infoItems"), String(info.items)) : null}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
<div style={{ display:"flex", justifyContent:"flex-end", marginTop:12 }}>
|
|
182
|
+
<button type="button" onClick={onClose} style={{ ...createBtnStyle, background:"transparent", border:"1px solid var(--border,#d0d0d0)", color:"var(--text,#222)" }}>{tt("files.cancel")}</button>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const createBtnStyle: Record<string, any> = { height:24, padding:"0 10px", borderRadius:5, cursor:"pointer", fontSize:12, fontWeight:600 };
|
|
189
|
+
|
|
190
|
+
// ── 移动/复制弹窗(两级选择:先 workspace,再目录)──
|
|
191
|
+
// mode="move":POST /move(renameSync,源消失);mode="copy":POST /copy(cpSync,源保留)。
|
|
192
|
+
// 默认 workspace = 当前打开的 root(不切 cwd),直接选目录即可确认。目录浏览复用 /api/cwd/browse。
|
|
193
|
+
function TransferDialog(props: { path: string; cwd: string; root: string; mode?: "move" | "copy"; t: TFn; onClose: () => void; onMoved: (newPath?: string) => void }) {
|
|
194
|
+
const { path, cwd, root, mode = "move", t: tt, onClose, onMoved } = props;
|
|
195
|
+
const isCopy = mode === "copy";
|
|
196
|
+
const srcName = lastSeg(path) || "";
|
|
197
|
+
const [ws, setWs] = useState<string>(root); // 当前 workspace(默认=当前打开的 root)
|
|
198
|
+
const [wsList, setWsList] = useState<string[]>([]); // 已知 workspace(sessions 聚合)
|
|
199
|
+
const [home, setHome] = useState("");
|
|
200
|
+
const [wsOpen, setWsOpen] = useState(false);
|
|
201
|
+
const [browsePath, setBrowsePath] = useState(root);
|
|
202
|
+
const [data, setData] = useState<any>(null);
|
|
203
|
+
const [loading, setLoading] = useState(false);
|
|
204
|
+
const [error, setError] = useState<string | null>(null);
|
|
205
|
+
const [busy, setBusy] = useState(false);
|
|
206
|
+
const [msg, setMsg] = useState<any>(null);
|
|
207
|
+
const [pickCustom, setPickCustom] = useState(false);
|
|
208
|
+
const loadBrowse = useCallback((target: string) => {
|
|
209
|
+
setLoading(true); setError(null);
|
|
210
|
+
fetch(apiUrl("/api/cwd/browse"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ path: target }) })
|
|
211
|
+
.then(async r => { const d = await r.json(); if(!r.ok || d.error) throw new Error(d.error||("HTTP "+r.status)); setData(d); setBrowsePath(d.path); })
|
|
212
|
+
.catch(e => setError(e&&e.message?e.message:String(e)))
|
|
213
|
+
.finally(()=>setLoading(false));
|
|
214
|
+
}, []);
|
|
215
|
+
useEffect(()=>{
|
|
216
|
+
fetch(apiUrl("/api/home")).then(r=>r.json()).then(d=>{ if(d.home) setHome(d.home); }).catch(()=>{});
|
|
217
|
+
fetch(apiUrl("/api/sessions"),{cache:"no-store"}).then(r=>r.json()).then(d=>{
|
|
218
|
+
const ps = recentProjects(d.sessions||[]).map((p:any)=>p.root).filter(Boolean);
|
|
219
|
+
const seen = new Set<string>(); const list: string[] = [];
|
|
220
|
+
for (const p of [root, ...ps]) { if (!seen.has(p)) { seen.add(p); list.push(p); } }
|
|
221
|
+
setWsList(list);
|
|
222
|
+
}).catch(()=>{});
|
|
223
|
+
loadBrowse(root);
|
|
224
|
+
}, []);
|
|
225
|
+
const switchWs = (p: string) => { setWs(p); setWsOpen(false); loadBrowse(p); };
|
|
226
|
+
const dirs = (data && data.directories) || []; const drives = data && data.drives;
|
|
227
|
+
const doMove = async ()=>{
|
|
228
|
+
if (busy || !browsePath) return;
|
|
229
|
+
setBusy(true); setMsg(null);
|
|
230
|
+
try {
|
|
231
|
+
const r = await fetch(apiUrl(isCopy ? "/api/extensions/aiti-folder/copy" : "/api/extensions/aiti-folder/move"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ path, targetDir: browsePath }) });
|
|
232
|
+
const d = await r.json().catch(()=>({}));
|
|
233
|
+
if (!r.ok || !d.ok) setMsg({ err: d.error || ("HTTP "+r.status) });
|
|
234
|
+
else { onMoved(d.path); onClose(); }
|
|
235
|
+
} catch(e:any){ setMsg({ err: e&&e.message?e.message:String(e) }); }
|
|
236
|
+
finally { setBusy(false); }
|
|
237
|
+
};
|
|
238
|
+
const rowStyle: Record<string, any> = { display:"flex", alignItems:"center", gap:8, width:"100%", padding:"0 12px", height:26, border:"none", background:"transparent", color:"var(--text)", cursor:"pointer", textAlign:"left", fontSize:12 };
|
|
239
|
+
return (
|
|
240
|
+
<div onClick={(e)=>{ if(e.target===e.currentTarget && !busy) onClose(); }} style={{ position:"fixed", inset:0, zIndex:520, background:"rgba(0,0,0,0.35)", display:"flex", alignItems:"center", justifyContent:"center" }}>
|
|
241
|
+
<div style={{ width:"min(540px,92vw)", maxHeight:"82vh", display:"flex", flexDirection:"column", background:"var(--bg-panel)", border:"1px solid var(--border)", borderRadius:10, boxShadow:"0 12px 40px rgba(0,0,0,0.3)", overflow:"hidden" }}>
|
|
242
|
+
{/* 标题 */}
|
|
243
|
+
<div style={{ display:"flex", alignItems:"center", gap:8, padding:"8px 12px", borderBottom:"1px solid var(--border)", background:"var(--bg)", flexShrink:0 }}>
|
|
244
|
+
<span style={{ fontSize:13, fontWeight:600, color:"var(--text)" }}>{tt(isCopy ? "files.copyToTitle" : "files.moveTitle")}</span>
|
|
245
|
+
<span style={{ flex:1, minWidth:0, fontFamily:"var(--font-mono,ui-monospace,monospace)", fontSize:12, color:"var(--text-dim)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }} title={path}>{srcName}</span>
|
|
246
|
+
<button onClick={()=>{ if(!busy) onClose(); }} title={tt("i18n.close")} style={{ height:26, padding:"0 10px", border:"1px solid var(--border)", borderRadius:5, background:"var(--bg-panel)", color:"var(--text)", cursor:"pointer", fontSize:12, flexShrink:0 }}>✕</button>
|
|
247
|
+
</div>
|
|
248
|
+
{/* 一级:workspace 选择(默认当前打开的 workspace;点下拉换其它 workspace) */}
|
|
249
|
+
<div style={{ position:"relative", display:"flex", alignItems:"center", gap:6, padding:"6px 12px", borderBottom:"1px solid var(--border)", background:"var(--bg)", flexShrink:0 }}>
|
|
250
|
+
<span style={{ fontSize:11, color:"var(--text-dim,#888)", fontWeight:600, flexShrink:0 }}>{tt("files.moveWorkspace")}</span>
|
|
251
|
+
<button type="button" onClick={()=>setWsOpen(v=>!v)} title={ws} style={{ flex:1, minWidth:0, display:"flex", alignItems:"center", gap:6, padding:"5px 8px", background:"var(--bg-hover)", border:"1px solid var(--border)", borderRadius:5, cursor:"pointer", fontSize:12, fontFamily:"var(--font-mono,ui-monospace,monospace)", color:"var(--text)", overflow:"hidden" }}>
|
|
252
|
+
<span style={{ flex:1, minWidth:0, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{displayCwd(ws, home)}</span>
|
|
253
|
+
<CaretIcon />
|
|
254
|
+
</button>
|
|
255
|
+
{wsOpen ? (
|
|
256
|
+
<div style={{ position:"absolute", top:"calc(100% + 4px)", left:12, right:12, zIndex:120, maxHeight:"min(44vh,300px)", overflowY:"auto", background:"var(--bg,#fff)", border:"1px solid var(--border,#d0d0d0)", borderRadius:8, boxShadow:"0 6px 20px rgba(0,0,0,0.15)", padding:4 }}>
|
|
257
|
+
{wsList.map((p)=>{ const cur=p===ws; return (
|
|
258
|
+
<button key={p} type="button" onClick={()=>switchWs(p)} title={p}
|
|
259
|
+
style={{ ...rowStyle, fontFamily:"var(--font-mono,ui-monospace,monospace)", color:cur?"var(--accent)":"var(--text)", fontWeight:cur?600:400 }}>
|
|
260
|
+
<FolderIcon open={false} />
|
|
261
|
+
<span style={{ flex:1, minWidth:0, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{displayCwd(p, home)}</span>
|
|
262
|
+
{cur ? <CheckIcon /> : null}
|
|
263
|
+
</button>
|
|
264
|
+
); })}
|
|
265
|
+
<button type="button" onClick={()=>{ setWsOpen(false); setPickCustom(true); }} style={rowStyle}>
|
|
266
|
+
<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>
|
|
267
|
+
<span style={{ fontSize:12, color:"var(--text-dim)" }}>{tt("sidebar.customPath")}</span>
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
) : null}
|
|
271
|
+
</div>
|
|
272
|
+
{/* 二级:目录浏览(在所选 workspace 内导航,当前目录=目标) */}
|
|
273
|
+
<div style={{ display:"flex", alignItems:"center", gap:6, padding:"6px 12px", borderBottom:"1px solid var(--border)", flexShrink:0, fontSize:12, color:"var(--text-muted)", fontFamily:"var(--font-mono)", overflowX:"auto", whiteSpace:"nowrap" }}>
|
|
274
|
+
{(data && data.parentPath!=null) ? <button onClick={()=>loadBrowse(data.parentPath)} title={tt("files.parentDir")} style={{ height:22, padding:"0 6px", border:"1px solid var(--border)", borderRadius:4, background:"var(--bg-panel)", color:"var(--text-muted)", cursor:"pointer", fontSize:12, flexShrink:0, display:"inline-flex", alignItems:"center" }}><MiArrowUp size={12} /></button> : null}
|
|
275
|
+
<span title={browsePath} style={{ overflow:"hidden", textOverflow:"ellipsis" }}>{browsePath||"/"}</span>
|
|
276
|
+
</div>
|
|
277
|
+
<div style={{ flex:1, overflowY:"auto", padding:"4px 0" }}>
|
|
278
|
+
{loading ? <div style={{ padding:"12px", fontSize:12, color:"var(--text-dim)" }}>{tt("files.loading")}</div> : null}
|
|
279
|
+
{error ? <div style={{ padding:"12px", fontSize:12, color:"var(--danger, #f87171)" }}>{error}</div> : null}
|
|
280
|
+
{(!loading && !error && drives && drives.length>0) ? drives.map((d:any)=> <button key={d.path} onClick={()=>loadBrowse(d.path)} style={rowStyle}><FolderIcon open={false} /><span style={{fontSize:12}}>{d.name}</span></button>) : null}
|
|
281
|
+
{(!loading && !error && dirs.length===0 && (!drives||drives.length===0)) ? <div style={{ padding:"12px", fontSize:12, color:"var(--text-dim)" }}>{tt("files.noSubdirs")}</div> : null}
|
|
282
|
+
{(!loading && !error) ? dirs.map((d:any)=> (
|
|
283
|
+
<button key={d.path} onClick={()=>loadBrowse(d.path)} title={d.path} style={rowStyle}>
|
|
284
|
+
<FolderIcon open={false} />
|
|
285
|
+
<span style={{ flex:1, fontSize:12, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{d.name}</span>
|
|
286
|
+
<span style={{fontSize:10,color:"var(--text-dim)"}}>→</span>
|
|
287
|
+
</button>
|
|
288
|
+
)) : null}
|
|
289
|
+
</div>
|
|
290
|
+
{/* 底部:目标路径 + 操作 */}
|
|
291
|
+
<div style={{ display:"flex", flexDirection:"column", gap:6, padding:"8px 12px", borderTop:"1px solid var(--border)", background:"var(--bg)", flexShrink:0 }}>
|
|
292
|
+
{msg ? <div style={{ fontSize:11, color: msg.err?"var(--danger, #f87171)":"var(--ok, #4ade80)" }}>{msg.err||msg.ok}</div> : null}
|
|
293
|
+
<div style={{ display:"flex", alignItems:"center", gap:8 }}>
|
|
294
|
+
<span style={{ flex:1, minWidth:0, fontFamily:"var(--font-mono,ui-monospace,monospace)", fontSize:11, color:"var(--text-dim,#888)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }} title={browsePath}>→ {tt("files.moveTarget")}: {browsePath||"/"}</span>
|
|
295
|
+
<button onClick={()=>{ if(!busy) onClose(); }} style={{ height:28, padding:"0 12px", border:"1px solid var(--border)", borderRadius:5, background:"var(--bg-panel)", color:"var(--text)", cursor:"pointer", fontSize:12 }}>{tt("files.cancel")}</button>
|
|
296
|
+
<button onClick={()=>void doMove()} disabled={busy||!browsePath} style={{ height:28, padding:"0 12px", border:"none", borderRadius:5, background:"var(--accent,#4f8ef7)", color:"#fff", cursor:busy?"default":"pointer", fontSize:12, fontWeight:600, opacity:(busy||!browsePath)?0.6:1 }}>{busy?"…":tt(isCopy ? "files.copyHere" : "files.moveHere")}</button>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
{pickCustom ? <DirectoryBrowser initialPath={ws} onSelect={(p)=>{ setPickCustom(false); switchWs(p); }} onCancel={()=>setPickCustom(false)} /> : null}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── 独立窗口打开(pi-web /viewer 路由:只含文件查看器的浏览器窗口)──
|
|
306
|
+
// 换票+握手+窗口尺寸收敛在 _shared/open-viewer-window(aiti-editor 与 aiti-folder 同用)。
|
|
307
|
+
// 注意不能带 noopener——子窗需要 window.opener 发握手(同源可信)。
|
|
308
|
+
function openNewWindow(kind: "file" | "folder", p: string, winCwd?: string) {
|
|
309
|
+
openViewerWindow(apiUrl, { path: p, kind, cwd: winCwd });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── 打开终端:独立查看窗(有 opener)→ postMessage 请主窗口的 aiti-cmd 代开,等回执;
|
|
313
|
+
// 主窗口优先宿主 pi-web.ui.onOpenTerminal(右侧工作区终端 tab,同 cwd 复用已开 tab;AppShell 单一入口);
|
|
314
|
+
// 服务缺席再查 __aitiCmd(遗留兜底);都没有回退服务端 open-terminal(桌面环境弹系统终端窗)。
|
|
315
|
+
// CWD=文件夹自身;选中文件=父目录(服务端判定)。返回错误文案,null=成功。
|
|
316
|
+
async function openTerminal(p: string, name?: string): Promise<string | null> {
|
|
317
|
+
// 独立查看窗判定在前(有 opener = 非主窗口):请主窗口(opener)的 aiti-cmd 代开,等回执。
|
|
318
|
+
const openerWin: any = (window as any).opener;
|
|
319
|
+
if (openerWin && !openerWin.closed) {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
const reqId = "ot-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8);
|
|
322
|
+
const shellName = name || lastSeg(p) || "bash";
|
|
323
|
+
const onMsg = (ev: MessageEvent) => {
|
|
324
|
+
if (ev.origin !== location.origin || ev.source !== openerWin) return;
|
|
325
|
+
const m: any = ev.data;
|
|
326
|
+
if (!m || m.source !== "aiti-cmd" || m.type !== "open-shell-result" || m.reqId !== reqId) return;
|
|
327
|
+
window.removeEventListener("message", onMsg);
|
|
328
|
+
resolve(m.ok ? null : (m.error || "创建终端失败"));
|
|
329
|
+
};
|
|
330
|
+
window.addEventListener("message", onMsg);
|
|
331
|
+
try {
|
|
332
|
+
openerWin.postMessage({ source: "aiti-ext", type: "open-shell", reqId, cwd: p, name: shellName }, location.origin);
|
|
333
|
+
} catch (e:any) {
|
|
334
|
+
window.removeEventListener("message", onMsg);
|
|
335
|
+
resolve(e && e.message ? e.message : String(e));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
setTimeout(() => { window.removeEventListener("message", onMsg); resolve("主窗口未响应(aiti-cmd 未启用?)"); }, 4000);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
// 主窗口:宿主统一入口(右侧工作区终端 tab;同 cwd 复用已开 tab,AppShell 单一入口防双开)。
|
|
342
|
+
// onOpenTerminal(cwd, forceNew?) 为 void 无回执,调用即成功。
|
|
343
|
+
// ⚠ __aitiCmd 全局已被宿主废弃(aiti-cmd 懒加载 v2 不再注入,实测恒 undefined);
|
|
344
|
+
// 掉到服务端回退会在「服务器」桌面弹终端窗,远程访问时用户不可见(曾表现为「点开终端失效」)。
|
|
345
|
+
try {
|
|
346
|
+
const hostUi = extSvc<{ get?: (name: string) => unknown }>("pi-web.ui");
|
|
347
|
+
const open = hostUi?.get?.("onOpenTerminal") as ((cwd: string, forceNew?: boolean) => void) | undefined;
|
|
348
|
+
if (typeof open === "function") { open(p); return null; }
|
|
349
|
+
} catch { /* 服务缺席——走后续回退 */ }
|
|
350
|
+
const bridge = (window as any).__aitiCmd;
|
|
351
|
+
if (bridge && typeof bridge.openShell === "function") {
|
|
352
|
+
const r = await bridge.openShell(p, name || lastSeg(p) || "bash");
|
|
353
|
+
return r && r.ok ? null : (r && r.error) || "创建终端失败";
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/open-terminal"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ path: p }) });
|
|
357
|
+
const d: any = await r.json().catch(() => ({}));
|
|
358
|
+
if (!r.ok || !d.ok) return (d && d.error) || "HTTP " + r.status;
|
|
359
|
+
return null;
|
|
360
|
+
} catch (e:any) {
|
|
361
|
+
return e && e.message ? e.message : String(e);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function NewWindowIcon() {
|
|
365
|
+
return <svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>;
|
|
366
|
+
}
|
|
367
|
+
// ── 审核之眼(Git Review 统一图标)──
|
|
368
|
+
// 线稿 = aiti-git-review extman 注册的 icon.svg(feather eye),与聊天窗工具条/编辑器菜单
|
|
369
|
+
// 的 GrEyeIcon 同一形状(aiti-git-review/client/src/git-review-app.tsx 导出同款)。勿单改此处形状。
|
|
370
|
+
function GrEyeIcon({ size = 13 }: { size?: number }) {
|
|
371
|
+
return (
|
|
372
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
|
|
373
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
374
|
+
<circle cx="12" cy="12" r="3" />
|
|
375
|
+
</svg>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 开放点数据/注册已抽到 ./folder-actions(拆包共享模块——与薄入口同实例,见该文件头)
|
|
380
|
+
import { folderActions } from "./folder-actions";
|
|
381
|
+
|
|
382
|
+
// ── 目录行「+」菜单:对该目录新建文件/文件夹、上传文件/文件夹(目标=该目录,非根)。──
|
|
383
|
+
// 菜单用 fixed 视口定位(树容器 overflow 滚动会裁剪 absolute 弹层);hovered 传入以在
|
|
384
|
+
// 菜单打开后鼠标移出时保持渲染(否则 onMouseLeave 卸载弹层)。
|
|
385
|
+
function DirOpsMenu(props: { dir: string; hovered: boolean; onChanged: (dir: string) => void; onRefresh?: () => void; onOpenFile?: (p: string) => void; onOpenFolder?: (p: string, name?: string) => void; onAtMention?: (rel: string, b: boolean) => void; onTrash?: (o: { path: string; name: string; isDir: boolean }) => void; t: TFn; root: string; cwd: string }) {
|
|
386
|
+
const { dir, hovered, onChanged, onRefresh, onOpenFile, onAtMention, onTrash, t: tt, root, cwd } = props;
|
|
387
|
+
const [open, setOpen] = useState(false);
|
|
388
|
+
const [kind, setKind] = useState<string | null>(null); // "file" | "folder" | null
|
|
389
|
+
const [name, setName] = useState("");
|
|
390
|
+
const [busy, setBusy] = useState(false);
|
|
391
|
+
const [msg, setMsg] = useState<any>(null);
|
|
392
|
+
const [renameMode, setRenameMode] = useState(false);
|
|
393
|
+
const [renameName, setRenameName] = useState("");
|
|
394
|
+
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
395
|
+
const fileRef = useRef<HTMLInputElement | null>(null), dirRef = useRef<HTMLInputElement | null>(null);
|
|
396
|
+
useEffect(()=>{ if(!open) return; const h=(e:any)=>{ if(!(e.target as Element).closest?.("[data-dirops]")) setOpen(false); }; const w=()=>setOpen(false); document.addEventListener("mousedown",h); document.addEventListener("wheel", w, {passive:true} as any); return ()=>{ document.removeEventListener("mousedown",h); document.removeEventListener("wheel", w); }; }, [open]);
|
|
397
|
+
const doCreate = async ()=>{
|
|
398
|
+
const n = name.trim(); if(!n||!kind||busy) return;
|
|
399
|
+
const endpoint = kind==="file" ? "/api/extensions/aiti-folder/create-file" : "/api/extensions/aiti-folder/create-folder";
|
|
400
|
+
setBusy(true);
|
|
401
|
+
try{
|
|
402
|
+
const r = await fetch(apiUrl(endpoint), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ dir, name:n }) });
|
|
403
|
+
const d = await r.json().catch(()=>({}));
|
|
404
|
+
if(!r.ok || !d.ok) setMsg({err: d.error || ("HTTP "+r.status)});
|
|
405
|
+
else {
|
|
406
|
+
setMsg({ok: (kind==="file"?tt("files.newFile"):tt("files.newFolder"))+" ✓"});
|
|
407
|
+
setOpen(false); setKind(null); setName(""); setPos(null);
|
|
408
|
+
onChanged?.(dir);
|
|
409
|
+
if(kind==="file" && d.path && onOpenFile) onOpenFile(d.path);
|
|
410
|
+
}
|
|
411
|
+
}catch(e:any){ setMsg({err: e&&e.message?e.message:String(e)}); }
|
|
412
|
+
finally{ setBusy(false); }
|
|
413
|
+
};
|
|
414
|
+
const doUpload = async (ev: any, isDirUpload: boolean)=>{
|
|
415
|
+
const files = Array.from(ev.target.files||[]); ev.target.value="";
|
|
416
|
+
if(files.length===0||busy) return;
|
|
417
|
+
setBusy(true); setMsg(null);
|
|
418
|
+
try{
|
|
419
|
+
if(isDirUpload){
|
|
420
|
+
const fd = new FormData(); fd.append("dir", dir);
|
|
421
|
+
for(const f of files as any[]){ const rel=(f.webkitRelativePath||f.name||"").replace(/\\/g,"/"); fd.append("files", f, rel||f.name); }
|
|
422
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/upload-tree"), { method:"POST", body:fd });
|
|
423
|
+
const d = await r.json().catch(()=>({}));
|
|
424
|
+
if(!r.ok || !d.ok) setMsg({err: d.error||("HTTP "+r.status)});
|
|
425
|
+
else { setMsg({ok: "↑"+(d.uploaded||[]).length+" / 跳"+(d.skipped||[]).length}); setOpen(false); onChanged?.(dir); }
|
|
426
|
+
} else {
|
|
427
|
+
const fd = new FormData(); fd.append("directory", dir); (files as any[]).forEach(f=>fd.append("files", f, f.name));
|
|
428
|
+
const r = await fetch(apiUrl("/api/files/upload?conflict=skip"), { method:"POST", body:fd });
|
|
429
|
+
const d = await r.json().catch(()=>({}));
|
|
430
|
+
if(!r.ok && !d.conflicts) setMsg({err: d.error||("HTTP "+r.status)});
|
|
431
|
+
else { setMsg({ok: "↑"+(d.uploaded||[]).length+" / 跳"+(d.skipped||[]).length}); setOpen(false); onChanged?.(dir); }
|
|
432
|
+
}
|
|
433
|
+
}catch(e:any){ setMsg({err: e&&e.message?e.message:String(e)}); }
|
|
434
|
+
finally{ setBusy(false); }
|
|
435
|
+
};
|
|
436
|
+
const doAt = ()=>{ if(!onAtMention) return; onAtMention(relTo(dir,cwd),false); setOpen(false); };
|
|
437
|
+
// 复制路径(绝对路径):菜单保持开,反馈写菜单底部 msg 行
|
|
438
|
+
const doCopyPath = ()=>{ copyTextToClipboard(dir).then(()=>setMsg({ok: tt("files.copyDone")}), (e:any)=>setMsg({err:(e&&e.message)?e.message:tt("files.copyFailed")})); };
|
|
439
|
+
const doTrash = ()=>{ if(!onTrash) return; onTrash({path:dir, name:lastSeg(dir)!, isDir:true}); setOpen(false); };
|
|
440
|
+
const doDownload = async ()=>{ try{
|
|
441
|
+
// 两步下载:POST 换票据(路径不进 URL,避免落入隧道/代理 access log)→ 带 ticket 的 GET 流式下载
|
|
442
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/archive-ticket"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ path: dir }) });
|
|
443
|
+
const d = await r.json().catch(()=>({} as any));
|
|
444
|
+
if (!r.ok || !d.ok || !d.ticket) { setMsg({err:d.error||("HTTP "+r.status)}); setOpen(false); return; }
|
|
445
|
+
const a=document.createElement("a"); a.href=apiUrl("/api/extensions/aiti-folder/archive?ticket="+encodeURIComponent(d.ticket)); a.download=lastSeg(dir)+".tar.gz"; a.click(); }catch(_){/* noop */} setOpen(false); };
|
|
446
|
+
const doRename = ()=>{ setRenameMode(true); setRenameName(lastSeg(dir)||""); };
|
|
447
|
+
const doRenameDir = async ()=>{ const n=renameName.trim(); if(!n||!renameMode||busy) return; setBusy(true); try{ const r=await fetch(apiUrl("/api/extensions/aiti-folder/rename"),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({path:dir,newName:n})}); const d=await r.json().catch(()=>({})); if(!r.ok||!d.ok) setMsg({err:d.error||("HTTP "+r.status)}); else{ setMsg({ok:(tt("files.renameSuccess") as string)||"✓"}); setRenameMode(false); setRenameName(""); setOpen(false); onChanged?.(dir); } }catch(e:any){ setMsg({err:e&&e.message?e.message:String(e)}); }finally{ setBusy(false); } };
|
|
448
|
+
// @ 到新session:目录【全路径】引用(新会话 cwd 可能不同,相对引用不可靠;
|
|
449
|
+
// relTo(root,cwd) 在 root==cwd 时返回空串→"@/ "丢路径,2026-09-08 修)写 pending composer 预填文本,
|
|
450
|
+
// 再切新建会话页(宿主 ChatInput 挂载后消费插入,不自动发送)
|
|
451
|
+
// 不随便切 CWD:目标目录在当前 CWD/关注路径内 → 保持;只在属于别的仓库时才切(doc-center 同款)
|
|
452
|
+
const doAtNew = async ()=>{ try{
|
|
453
|
+
const p = dir + "/";
|
|
454
|
+
const text = buildAtText(p);
|
|
455
|
+
await openNewSessionWithExtSvc({ text, targetDir: dir, targetPath: dir, currentCwd: cwd, apiUrl, extSvc });
|
|
456
|
+
}catch{/* noop */} setOpen(false); };
|
|
457
|
+
const [infoOpen, setInfoOpen] = useState(false);
|
|
458
|
+
// 移动/复制弹窗:null=关,"move"/"copy"=弹窗模式(共用 TransferDialog)
|
|
459
|
+
const [transfer, setTransfer] = useState<null | "move" | "copy">(null);
|
|
460
|
+
const toggle = (e: any)=>{
|
|
461
|
+
e.stopPropagation();
|
|
462
|
+
if(open){ setOpen(false); return; }
|
|
463
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
464
|
+
// 菜单固定视口定位:left 夹在视口内;底部空间不足时向上翻转,避免被屏幕裁掉。
|
|
465
|
+
// 估算菜单高度:~17 项 + 6 分区头 + 6 分隔线 + 容器 padding + gap + msg 行(见时)+ 输入框态更高。
|
|
466
|
+
// 写得稍大一些(590)确保翻转判断不会被低估值误导;翻转后顶部也不够就贴顶。
|
|
467
|
+
const mh = 590;
|
|
468
|
+
const menuWidth = 190;
|
|
469
|
+
const left = Math.max(8, Math.min(r.right - menuWidth, window.innerWidth - menuWidth - 8));
|
|
470
|
+
const overflowDown = r.bottom + 4 + mh > window.innerHeight;
|
|
471
|
+
const overflowUp = r.top - mh - 4 < 0;
|
|
472
|
+
let top: number;
|
|
473
|
+
if (overflowDown && !overflowUp) {
|
|
474
|
+
// 翻转向上
|
|
475
|
+
top = Math.max(8, r.top - mh - 4);
|
|
476
|
+
} else if (overflowDown && overflowUp) {
|
|
477
|
+
// 上下都不够:贴顶,给用户滚动的余地(菜单高度超出视口本身就极少见)
|
|
478
|
+
top = 8;
|
|
479
|
+
} else {
|
|
480
|
+
top = r.bottom + 4;
|
|
481
|
+
}
|
|
482
|
+
setPos({ left, top });
|
|
483
|
+
setOpen(true);
|
|
484
|
+
};
|
|
485
|
+
const visible = hovered || open || !!transfer;
|
|
486
|
+
return (
|
|
487
|
+
<div data-dirops={1} onClick={e=>e.stopPropagation()} style={{ position:"absolute", right:4, top:1, zIndex:40, display: visible?undefined:"none" }}>
|
|
488
|
+
<button type="button" onClick={toggle} title={tt("files.rename")+" / "+tt("files.insertPath")+" / "+tt("files.download")+" / "+tt("files.trash")+" / "+tt("files.newFile")}
|
|
489
|
+
style={{ width:24, height:22, padding:0, display:"flex", alignItems:"center", justifyContent:"center", border:"1px solid var(--border)", borderRadius:4, background: open?"var(--bg-selected)":"var(--bg-panel)", color: open?"var(--accent)":"var(--text-dim)", cursor:"pointer", fontSize:14, fontWeight:700, lineHeight:1, transition:"color 0.12s, background 0.12s" }}>⋮</button>
|
|
490
|
+
{open ? (
|
|
491
|
+
<div style={{ position:"fixed", left:pos!.left, top:pos!.top, zIndex:400, minWidth:170, padding:6, background:"var(--bg,#fff)", border:"1px solid var(--border,#d0d0d0)", borderRadius:10, boxShadow:"0 4px 12px rgba(0,0,0,0.15)", fontSize:12, display:"flex", flexDirection:"column", gap:2 }}>
|
|
492
|
+
{kind ? (
|
|
493
|
+
<div style={{display:"flex",flexDirection:"column",gap:6,padding:"2px 2px 4px"}}>
|
|
494
|
+
<div style={{fontSize:11,color:"var(--text-dim,#888)",fontWeight:600}}>{kind==="file"?tt("files.newFile"):tt("files.newFolder")}</div>
|
|
495
|
+
<input autoFocus value={name} onChange={e=>setName(e.target.value)} onKeyDown={e=>{ if(e.key==="Enter") void doCreate(); if(e.key==="Escape") setKind(null); }} placeholder={tt("files.namePlaceholderFile")}
|
|
496
|
+
style={{width:"100%",boxSizing:"border-box",fontSize:12,fontFamily:"var(--font-mono,ui-monospace,monospace)",padding:"5px 8px",border:"1px solid var(--border,#d0d0d0)",borderRadius:5,outline:"none",background:"var(--bg,#fff)",color:"var(--text,#222)"}} />
|
|
497
|
+
<div style={{display:"flex",gap:6,justifyContent:"flex-end"}}>
|
|
498
|
+
<button type="button" onClick={()=>setKind(null)} style={{...createBtnStyle,background:"transparent",border:"1px solid var(--border,#d0d0d0)",color:"var(--text,#222)"}}>{tt("files.cancel")}</button>
|
|
499
|
+
<button type="button" onClick={()=>void doCreate()} disabled={!name.trim()||busy} style={{...createBtnStyle,background:"var(--accent,#4f8ef7)",color:"#fff",border:"none",opacity:!name.trim()||busy?0.5:1}}>{busy?"…":tt("files.create")}</button>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
) : (renameMode ? (
|
|
503
|
+
<div style={{display:"flex",flexDirection:"column",gap:6,padding:"2px 2px 4px"}}>
|
|
504
|
+
<div style={{fontSize:11,color:"var(--text-dim,#888)",fontWeight:600}}>{tt("files.rename")}</div>
|
|
505
|
+
<input autoFocus value={renameName} onChange={e=>setRenameName(e.target.value)} onKeyDown={e=>{ if(e.key==="Enter") void doRenameDir(); if(e.key==="Escape") setRenameMode(false); }} placeholder={(tt("files.renamePlaceholder") as string)||"输入新文件名"}
|
|
506
|
+
style={{width:"100%",boxSizing:"border-box",fontSize:12,fontFamily:"var(--font-mono,ui-monospace,monospace)",padding:"5px 8px",border:"1px solid var(--border,#d0d0d0)",borderRadius:5,outline:"none",background:"var(--bg,#fff)",color:"var(--text,#222)"}} />
|
|
507
|
+
<div style={{display:"flex",gap:6,justifyContent:"flex-end"}}>
|
|
508
|
+
<button type="button" onClick={()=>setRenameMode(false)} style={{...createBtnStyle,background:"transparent",border:"1px solid var(--border,#d0d0d0)",color:"var(--text,#222)"}}>{tt("files.cancel")}</button>
|
|
509
|
+
<button type="button" onClick={()=>void doRenameDir()} disabled={!renameName.trim()||busy} style={{...createBtnStyle,background:"var(--accent,#4f8ef7)",color:"#fff",border:"none",opacity:!renameName.trim()||busy?0.5:1}}>{busy?"…":tt("files.confirm")}</button>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
) : [
|
|
513
|
+
// ── 打开/查看 ──
|
|
514
|
+
menuHead(tt("files.menuOpen"), "h-open"),
|
|
515
|
+
menuItem(<MiRefresh/>, tt("files.refresh"), ()=>{ setOpen(false); onRefresh && onRefresh(); }, "rf"),
|
|
516
|
+
menuItem(<NewWindowIcon/>, tt("files.openNewWindow"), ()=>{ setOpen(false); openNewWindow("folder", dir); }, "nw"),
|
|
517
|
+
menuItem(<TerminalIcon/>, tt("files.openTerminal"), ()=>{ setOpen(false); void openTerminal(dir).then(err=>{ if (err) setMsg({ err }); }); }, "ot"),
|
|
518
|
+
menuItem(<MiInfo/>, tt("files.entryInfo"), ()=>setInfoOpen(true), "info"), // 菜单保持开:容器 visible=hovered||open,关菜单会连带隐藏弹层
|
|
519
|
+
// ── 路径引用(复制路径邻接 @)──
|
|
520
|
+
menuSep("s-ref"), menuHead(tt("files.menuRef"), "h-ref"),
|
|
521
|
+
onAtMention ? menuItem(<MiAt/>, tt("files.insertPath"), doAt, "at") : null,
|
|
522
|
+
menuItem(<MiCopy/>, tt("files.copyPath"), doCopyPath, "cp"),
|
|
523
|
+
menuItem(<MiAt/>, tt("files.atNewSession"), doAtNew, "atn"),
|
|
524
|
+
// ── 整理(复制到邻接 移动到)──
|
|
525
|
+
menuSep("s-org"), menuHead(tt("files.menuOrganize"), "h-org"),
|
|
526
|
+
menuItem(<MiPencil/>, tt("files.rename"), doRename, "rn"),
|
|
527
|
+
menuItem(<MiMove/>, tt("files.move"), ()=>{ setTransfer("move"); }, "mv"),
|
|
528
|
+
menuItem(<MiCopyTo/>, tt("files.copyTo"), ()=>{ setTransfer("copy"); }, "cpto"),
|
|
529
|
+
onTrash ? menuItem(<MiTrash/>, tt("files.trash"), doTrash, "tr") : null,
|
|
530
|
+
// ── 新建 / 上传 ──
|
|
531
|
+
menuSep("s-new"), menuHead(tt("files.menuCreate"), "h-new"),
|
|
532
|
+
menuItem(<MiFilePlus/>, tt("files.newFile"), ()=>{ setKind("file"); setName(""); }, "nf"),
|
|
533
|
+
menuItem(<MiFolderPlus/>, tt("files.newFolder"), ()=>{ setKind("folder"); setName(""); }, "nd"),
|
|
534
|
+
menuItem(<MiUpload/>, tt("files.uploadFile"), ()=>{ setOpen(false); if(fileRef.current) fileRef.current.click(); }, "uf"),
|
|
535
|
+
menuItem(<MiUpload/>, tt("files.uploadFolder"), ()=>{ setOpen(false); if(dirRef.current) dirRef.current.click(); }, "ud"),
|
|
536
|
+
// ── 导出 ──
|
|
537
|
+
menuSep("s-exp"), menuHead(tt("files.menuExport"), "h-exp"),
|
|
538
|
+
menuItem(<MiDownload/>, tt("files.download"), doDownload, "dl"),
|
|
539
|
+
// ── Git ──
|
|
540
|
+
root ? menuSep("s-git") : null,
|
|
541
|
+
root ? menuHead(tt("files.menuGit"), "h-git") : null,
|
|
542
|
+
root ? (
|
|
543
|
+
<button type="button" key="gr" onClick={(e)=>{e.stopPropagation();try{(grOpen())&&(grOpen())({scope:{kind:"directory",path:root}});grWork()&&grWork();}catch(_){/* noop */}setOpen(false)}} style={{display:"flex",alignItems:"center",gap:6,width:"100%",padding:"5px 10px",border:"none",background:"transparent",color:"var(--accent)",cursor:"pointer",textAlign:"left",fontSize:12,borderRadius:6,whiteSpace:"nowrap"}}>
|
|
544
|
+
<GrEyeIcon size={13} />
|
|
545
|
+
Git Review
|
|
546
|
+
</button>
|
|
547
|
+
) : null,
|
|
548
|
+
])}
|
|
549
|
+
{msg ? <div style={{fontSize:10,color:msg.err?"var(--danger, #f87171)":"var(--ok, #4ade80)",padding:"2px 4px"}}>{msg.err?msg.err:(msg.ok||"")}</div> : null}
|
|
550
|
+
</div>
|
|
551
|
+
) : null}
|
|
552
|
+
<input ref={fileRef} type="file" multiple hidden onChange={e=>void doUpload(e,false)} />
|
|
553
|
+
<input ref={dirRef} type="file" multiple hidden webkitdirectory="" onChange={e=>void doUpload(e,true)} />
|
|
554
|
+
{infoOpen ? <EntryInfoModal path={dir} onClose={()=>{ setInfoOpen(false); setOpen(false); }} t={tt} onOpenFolder={props.onOpenFolder} /> : null}
|
|
555
|
+
{transfer ? <TransferDialog path={dir} cwd={cwd} root={root} mode={transfer} t={tt} onClose={()=>{ setTransfer(null); }} onMoved={(newPath)=>{ setTransfer(null); setOpen(false); onChanged?.(dir); }} /> : null}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── 文件行操作区(与文件夹 DirOpsMenu 同款:大屏悬停显示操作按钮;小屏无悬停→点击后部等效悬停,
|
|
561
|
+
// 再点「+」展开菜单。点击文件名仍开文件;点击操作区不冒泡到行)。
|
|
562
|
+
function FileOpsMenu(props: { filePath: string; cwd: string; root: string; onOpenFile?: (p: string) => void; onOpenFolder?: (p: string, name?: string) => void; onAtMention?: (rel: string, b: boolean) => void; onTrash?: (o: { path: string; name: string; isDir: boolean }) => void; hovered: boolean; t: TFn; onRefresh?: (p?: string) => void }) {
|
|
563
|
+
const { filePath, cwd, root, onOpenFile, onAtMention, onTrash, hovered, t: tt, onRefresh } = props;
|
|
564
|
+
const [open, setOpen] = useState(false); const [pos, setPos] = useState<{left:number;top:number}|null>(null);
|
|
565
|
+
const [renameMode, setRenameMode] = useState(false);
|
|
566
|
+
const [renameName, setRenameName] = useState("");
|
|
567
|
+
const [msg, setMsg] = useState<any>(null);
|
|
568
|
+
useEffect(()=>{ if(!open) return; const h=(e:any)=>{ if(!(e.target as Element).closest?.("[data-fileops]")) setOpen(false); }; const w=()=>setOpen(false); document.addEventListener("mousedown",h); document.addEventListener("wheel",w,{passive:true} as any); return ()=>{ document.removeEventListener("mousedown",h); document.removeEventListener("wheel",w); }; }, [open]);
|
|
569
|
+
const toggle = (e:any)=>{ e.stopPropagation(); if(open){ setOpen(false); return; }
|
|
570
|
+
const r=e.currentTarget.getBoundingClientRect();
|
|
571
|
+
// 菜单固定视口定位:left 夹在视口内;底部空间不足时向上翻转,避免被屏幕裁掉。
|
|
572
|
+
// 估算菜单高度:~12 项 + 5 分区头 + 5 分隔线 + padding + gap + msg 行 ≈ 440,用 460 留 buffer。
|
|
573
|
+
const mh=460; const menuWidth=168; const left=Math.max(8,Math.min(r.right-menuWidth,window.innerWidth-menuWidth-8)); const overflowDown=r.bottom+4+mh>window.innerHeight; const overflowUp=r.top-mh-4<0; let top: number;
|
|
574
|
+
if(overflowDown&&!overflowUp) top=Math.max(8,r.top-mh-4);
|
|
575
|
+
else if(overflowDown&&overflowUp) top=8;
|
|
576
|
+
else top=r.bottom+4;
|
|
577
|
+
setPos({left,top}); setOpen(true); };
|
|
578
|
+
const handleAt = ()=>{ if(!onAtMention) return; onAtMention(relTo(filePath,cwd),false); setOpen(false); };
|
|
579
|
+
// 复制路径(绝对路径):菜单保持开,反馈写菜单底部 msg 行
|
|
580
|
+
const doCopyPath = ()=>{ copyTextToClipboard(filePath).then(()=>setMsg({ok: tt("files.copyDone")}), (e:any)=>setMsg({err:(e&&e.message)?e.message:tt("files.copyFailed")})); };
|
|
581
|
+
const handleTrash = ()=>{ if(!onTrash) return; onTrash({path:filePath, name:lastSeg(filePath)!, isDir:false}); setOpen(false); };
|
|
582
|
+
const doDownload = ()=>{ try{
|
|
583
|
+
// 两步下载:POST 换票据(路径不进 URL,避免落入隧道/代理 access log)→ 带 ticket 的 GET 流式下载
|
|
584
|
+
fetch(apiUrl("/api/files/ticket"),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({path:filePath})})
|
|
585
|
+
.then(r=>r.json()).then(d=>{ if(!d.ticket) throw new Error(d.error||"no ticket");
|
|
586
|
+
const a=document.createElement("a"); a.href=apiUrl("/api/files/_t/"+encodeURIComponent(d.ticket)); a.download=lastSeg(filePath)!; document.body.appendChild(a); a.click(); a.remove(); })
|
|
587
|
+
.catch(()=>{});
|
|
588
|
+
}catch(_){/* noop */} onRefresh && onRefresh(); setOpen(false); };
|
|
589
|
+
const doRename = ()=>{ setRenameMode(true); setRenameName(lastSeg(filePath)||""); };
|
|
590
|
+
// @ 到新session:文件【全路径】引用(同 DirOpsMenu,相对引用在新会话不可靠);
|
|
591
|
+
// CWD 判定目标 = 文件所在目录(与 aiti-editor 同口径);无 cwd 兜底浏览根
|
|
592
|
+
const doAtNew = async ()=>{ try{
|
|
593
|
+
const rel = filePath;
|
|
594
|
+
const text = buildAtText(rel);
|
|
595
|
+
const targetDir = filePath.slice(0, filePath.lastIndexOf("/")) || root;
|
|
596
|
+
await openNewSessionWithExtSvc({ text, targetDir, targetPath: filePath, currentCwd: cwd, apiUrl, extSvc });
|
|
597
|
+
}catch{/* noop */} setOpen(false); };
|
|
598
|
+
const [infoOpen, setInfoOpen] = useState(false);
|
|
599
|
+
// 移动/复制弹窗:null=关,"move"/"copy"=弹窗模式(共用 TransferDialog)
|
|
600
|
+
const [transfer, setTransfer] = useState<null | "move" | "copy">(null);
|
|
601
|
+
const doRenameFile = async ()=>{ const n=renameName.trim(); if(!n||!renameMode) return; try{ const r=await fetch(apiUrl("/api/extensions/aiti-folder/rename"),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({path:filePath,newName:n})}); const d=await r.json().catch(()=>({})); if(!r.ok||!d.ok){ alert(d.error||("HTTP "+r.status)); return; } setRenameMode(false); setRenameName(""); setOpen(false); onRefresh && onRefresh(); }catch(e:any){ alert(e&&e.message?e.message:String(e)); } };
|
|
602
|
+
const visible = hovered || open || !!transfer;
|
|
603
|
+
return (
|
|
604
|
+
<div data-fileops={1} onClick={e=>e.stopPropagation()} style={{ position:"absolute", right:4, top:1, display:"flex", alignItems:"center", gap:3, zIndex:40, visibility:visible?"visible":"hidden" }}>
|
|
605
|
+
{onAtMention ? <button onClick={e=>{e.stopPropagation(); handleAt();}} title={tt("files.insertPath")} style={{ width:22, height:22, padding:0, display:"flex", alignItems:"center", justifyContent:"center", border:"1px solid var(--border)", borderRadius:4, background:"var(--bg-panel)", color:"var(--accent)", cursor:"pointer", fontSize:12, fontWeight:700, lineHeight:1, flexShrink:0 }}>@</button> : null}
|
|
606
|
+
{onTrash ? <button onClick={e=>{e.stopPropagation(); handleTrash();}} title={tt("files.trash")} style={{ width:22, height:22, padding:0, display:"flex", alignItems:"center", justifyContent:"center", border:"1px solid var(--border)", borderRadius:4, background:"var(--bg-panel)", color:"var(--danger, #ef4444)", cursor:"pointer", lineHeight:1, flexShrink:0 }}><MiTrash/></button> : null}
|
|
607
|
+
<button onClick={()=>doDownload()} title={tt("files.download")} style={{ width:22, height:22, padding:0, display:"flex", alignItems:"center", justifyContent:"center", border:"1px solid var(--border)", borderRadius:4, background:"var(--bg-panel)", color:"var(--accent)", cursor:"pointer", fontSize:12, fontWeight:700, lineHeight:1, flexShrink:0 }}><MiDownload/></button>
|
|
608
|
+
<button onClick={toggle} title={tt("files.rename")+" / "+tt("files.download")+" / "+tt("files.insertPath")+" / "+tt("files.trash")} style={{ width:24, height:22, padding:0, display:"flex", alignItems:"center", justifyContent:"center", border:"1px solid var(--border)", borderRadius:4, background:open?"var(--bg-selected)":"var(--bg-panel)", color:open?"var(--accent)":"var(--text-dim)", cursor:"pointer", fontSize:14, fontWeight:700, lineHeight:1, flexShrink:0, transition:"color 0.12s, background 0.12s" }}>⋮</button>
|
|
609
|
+
{open ? (
|
|
610
|
+
<div style={{ position:"fixed", left:pos!.left, top:pos!.top, zIndex:400, minWidth:150, padding:6, background:"var(--bg,#fff)", border:"1px solid var(--border,#d0d0d0)", borderRadius:10, boxShadow:"0 4px 12px rgba(0,0,0,0.15)", fontSize:12, display:"flex", flexDirection:"column", gap:2 }}>
|
|
611
|
+
{renameMode ? (
|
|
612
|
+
<div key="ri" style={{display:"flex",flexDirection:"column",gap:6,padding:"2px 2px 4px"}}>
|
|
613
|
+
<div style={{fontSize:11,color:"var(--text-dim,#888)",fontWeight:600}}>{tt("files.rename")}</div>
|
|
614
|
+
<input autoFocus value={renameName} onChange={e=>setRenameName(e.target.value)} onKeyDown={e=>{ if(e.key==="Enter") void doRenameFile(); if(e.key==="Escape") setRenameMode(false); }} placeholder={(tt("files.renamePlaceholder") as string)||"输入新文件名"}
|
|
615
|
+
style={{width:"100%",boxSizing:"border-box",fontSize:12,fontFamily:"var(--font-mono,ui-monospace,monospace)",padding:"5px 8px",border:"1px solid var(--border,#d0d0d0)",borderRadius:5,outline:"none",background:"var(--bg,#fff)",color:"var(--text,#222)"}} />
|
|
616
|
+
<div style={{display:"flex",gap:6,justifyContent:"flex-end"}}>
|
|
617
|
+
<button type="button" onClick={()=>setRenameMode(false)} style={{...createBtnStyle,background:"transparent",border:"1px solid var(--border,#d0d0d0)",color:"var(--text,#222)"}}>{tt("files.cancel")}</button>
|
|
618
|
+
<button type="button" onClick={()=>void doRenameFile()} disabled={!renameName.trim()} style={{...createBtnStyle,background:"var(--accent,#4f8ef7)",color:"#fff",border:"none",opacity:!renameName.trim()?0.5:1}}>{tt("files.confirm")}</button>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
) : [
|
|
622
|
+
// ── 打开/查看 ──
|
|
623
|
+
menuHead(tt("files.menuOpen"), "h-open"),
|
|
624
|
+
menuItem(<NewWindowIcon/>, tt("files.openNewWindow"), ()=>{ setOpen(false); openNewWindow("file", filePath); }, "nw"),
|
|
625
|
+
menuItem(<TerminalIcon/>, tt("files.openTerminal"), ()=>{ setOpen(false); void openTerminal(dirOf(filePath), lastSeg(filePath)).then(err=>{ if (err) alert(err); }); }, "ot"),
|
|
626
|
+
menuItem(<MiInfo/>, tt("files.entryInfo"), ()=>setInfoOpen(true), "info"), // 菜单保持开:容器 visibility=hovered||open,关菜单会连带隐藏弹层
|
|
627
|
+
// ── 路径引用(复制路径邻接 @)──
|
|
628
|
+
menuSep("s-ref"), menuHead(tt("files.menuRef"), "h-ref"),
|
|
629
|
+
onAtMention ? menuItem(<MiAt/>, tt("files.insertPath"), handleAt, "at") : null,
|
|
630
|
+
menuItem(<MiCopy/>, tt("files.copyPath"), doCopyPath, "cp"),
|
|
631
|
+
menuItem(<MiAt/>, tt("files.atNewSession"), doAtNew, "atn"),
|
|
632
|
+
// ── 整理(复制到邻接 移动到)──
|
|
633
|
+
menuSep("s-org"), menuHead(tt("files.menuOrganize"), "h-org"),
|
|
634
|
+
menuItem(<MiPencil/>, tt("files.rename"), doRename, "rn"),
|
|
635
|
+
menuItem(<MiMove/>, tt("files.move"), ()=>{ setTransfer("move"); }, "mv"),
|
|
636
|
+
menuItem(<MiCopyTo/>, tt("files.copyTo"), ()=>{ setTransfer("copy"); }, "cpto"),
|
|
637
|
+
onTrash ? menuItem(<MiTrash/>, tt("files.trash"), handleTrash, "tr") : null,
|
|
638
|
+
// ── 导出 ──
|
|
639
|
+
menuSep("s-exp"), menuHead(tt("files.menuExport"), "h-exp"),
|
|
640
|
+
menuItem(<MiDownload/>, tt("files.download"), doDownload, "dl"),
|
|
641
|
+
// ── Git ──
|
|
642
|
+
root ? menuSep("s-git") : null,
|
|
643
|
+
root ? menuHead(tt("files.menuGit"), "h-git") : null,
|
|
644
|
+
root ? (
|
|
645
|
+
<button type="button" key="gr" onClick={(e)=>{ e.stopPropagation(); try{ if(grOpen()){ grOpen({ scope:{ kind:"directory", path: root } }); } grWork() && grWork(); }catch(_){/* noop */} setOpen(false); }}
|
|
646
|
+
style={{ display:"flex", alignItems:"center", gap:6, width:"100%", padding:"5px 10px", border:"none", background:"transparent", color:"var(--accent)", cursor:"pointer", textAlign:"left", fontSize:12, borderRadius:6, whiteSpace:"nowrap" }}>
|
|
647
|
+
<GrEyeIcon size={13} />
|
|
648
|
+
Git Review
|
|
649
|
+
</button>
|
|
650
|
+
) : null,
|
|
651
|
+
]}
|
|
652
|
+
{msg ? <div style={{fontSize:10,color:msg.err?"var(--danger, #f87171)":"var(--ok, #4ade80)",padding:"2px 4px"}}>{msg.err?msg.err:(msg.ok||"")}</div> : null}
|
|
653
|
+
</div>
|
|
654
|
+
) : null}
|
|
655
|
+
{infoOpen ? <EntryInfoModal path={filePath} onClose={()=>{ setInfoOpen(false); setOpen(false); }} t={tt} onOpenFolder={props.onOpenFolder} /> : null}
|
|
656
|
+
{transfer ? <TransferDialog path={filePath} cwd={cwd} root={root} mode={transfer} t={tt} onClose={()=>{ setTransfer(null); }} onMoved={(newPath)=>{ setTransfer(null); setOpen(false); onRefresh && onRefresh(); }} /> : null}
|
|
657
|
+
</div>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ── 变更区段文件行:与树内文件行同款 hover 操作区(FileOpsMenu:@/🗑/⬇️/⋮ 菜单)──
|
|
662
|
+
// 相对路径显示走 relFromBase(分隔符/大小写兼容 Windows——git status 的 filePath 是
|
|
663
|
+
// 服务端 path.resolve 产物反斜杠形式,直接 startsWith(root+"/") 必失败→显示完整绝对路径)。
|
|
664
|
+
function ChangedFileRow(o: { st: any; root: string; cwd: string; onOpenFile: (p: string) => void; onOpenFolder?: (p: string, name?: string) => void; onAtMention?: (rel: string, b: boolean) => void; onTrash?: (x: { path: string; name: string; isDir: boolean }) => void; t: TFn; onRefresh: () => void; staged?: boolean; gitOp?: (kind: "stage" | "unstage" | "discard", rel: string) => void }) {
|
|
665
|
+
const { st, root, cwd, onOpenFile, onAtMention, onTrash, t: tt, onRefresh, staged, gitOp } = o;
|
|
666
|
+
const [hovered, setHovered] = useState(false);
|
|
667
|
+
const rel = relFromBase(st.filePath, root) || st.filePath;
|
|
668
|
+
const gitBtn = (title: string, label: string, fn: () => void, key: string, color = "var(--text-dim)") => (
|
|
669
|
+
<button key={key} type="button" title={title} aria-label={title}
|
|
670
|
+
onClick={(e) => { e.stopPropagation(); fn(); }}
|
|
671
|
+
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 18, height: 18, padding: 0, border: "none", background: "none", color, cursor: "pointer", borderRadius: 4, fontSize: 11, lineHeight: 1, flexShrink: 0 }}>{label}</button>
|
|
672
|
+
);
|
|
673
|
+
return (
|
|
674
|
+
<div onClick={()=>onOpenFile(st.filePath)} title={st.filePath}
|
|
675
|
+
onMouseEnter={()=>setHovered(true)} onMouseLeave={()=>setHovered(false)}
|
|
676
|
+
style={{ position:"relative", display:"flex", alignItems:"center", gap:6, paddingLeft:10, paddingRight:8, height:24, cursor:"pointer", borderRadius:4, userSelect:"none", background:hovered?"var(--bg-hover)":"transparent" }}>
|
|
677
|
+
<GitBadge status={st} t={tt} />
|
|
678
|
+
<span style={{fontSize:12,color:"var(--text)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",flex:1}}>{rel}</span>
|
|
679
|
+
{gitOp ? (
|
|
680
|
+
<span data-fileops style={{ display:"flex", alignItems:"center", gap:2, opacity:hovered?1:0, transition:"opacity 0.12s", flexShrink:0 }}>
|
|
681
|
+
{gitBtn(staged ? ct("git.unstageTitle") : ct("git.stageTitle"), staged ? "−" : "+", () => gitOp(staged ? "unstage" : "stage", rel), "st")}
|
|
682
|
+
{gitBtn(ct("git.diffTitle"), "diff", () => { try { const open = grOpen(); if (open) open({ scope: { kind: "file", path: st.filePath } }); grWork() && grWork(); } catch (_) { /* noop */ } }, "df")}
|
|
683
|
+
{gitBtn(ct("git.discardTitle"), "✕", () => { if (window.confirm(ct("git.discardConfirm", { path: rel }))) gitOp("discard", rel); }, "ds", "#d6a84b")}
|
|
684
|
+
</span>
|
|
685
|
+
) : null}
|
|
686
|
+
<FileOpsMenu filePath={st.filePath} cwd={cwd} root={root} onOpenFile={onOpenFile} onOpenFolder={o.onOpenFolder} onAtMention={onAtMention} onTrash={onTrash} hovered={hovered} t={tt} onRefresh={onRefresh} />
|
|
687
|
+
</div>
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function TreeNode(o: any) {
|
|
692
|
+
const node=o.node, depth=o.depth, cwd=o.cwd, expanded=o.expanded, onToggle=o.onToggle, onOpenFile=o.onOpenFile, onOpenFolder=o.onOpenFolder, onAtMention=o.onAtMention, onTrash=o.onTrash, onChanged=o.onChanged, gitByPath=o.gitByPath, changedDirs=o.changedDirs, hlPath=o.highlightPath, tt=o.t, root=o.root;
|
|
693
|
+
const showIgnored: boolean = o.showIgnored === true;
|
|
694
|
+
const showIgnoredRef = useRef(showIgnored); showIgnoredRef.current = showIgnored;
|
|
695
|
+
const refreshKey: number = o.refreshKey ?? 0;
|
|
696
|
+
const [children, setChildren] = useState<any[]>(node.children||[]);
|
|
697
|
+
const [loaded, setLoaded] = useState<boolean>(node.loaded||false);
|
|
698
|
+
const [loading, setLoading] = useState(false);
|
|
699
|
+
const [hovered, setHovered] = useState(false);
|
|
700
|
+
// 子树刷新序号:本节点被强拉时递增,向下传给已挂载的子孙节点,实现只刷本子树不动其他分支
|
|
701
|
+
const [downSeq, setDownSeq] = useState(0);
|
|
702
|
+
const parentSeq: number = o.refreshSeq ?? 0;
|
|
703
|
+
const open = expanded.has(node.fullPath);
|
|
704
|
+
const loadChildren = useCallback(async (force?: boolean)=>{ if(loaded&&!force) return; setLoading(true); try{ const e=await fetchEntries(apiUrl, node.fullPath, { force, showIgnored: showIgnoredRef.current }); setChildren(e); setLoaded(true);}catch{/* noop */}finally{setLoading(false);} }, [loaded, node.fullPath]);
|
|
705
|
+
useEffect(()=>{ if(open&&!loaded) loadChildren(); }, [open, loaded, loadChildren]);
|
|
706
|
+
// 根工具栏「刷新」:refreshKey 变化时已加载过的子树强制重拉(含折叠态,再展开即新内容)
|
|
707
|
+
const rkRef = useRef(refreshKey);
|
|
708
|
+
useEffect(()=>{ if(rkRef.current===refreshKey) return; rkRef.current=refreshKey; if(loaded) loadChildren(true); }, [refreshKey, loaded, loadChildren]);
|
|
709
|
+
// 父节点发起的子树刷新:强拉自己并继续向自己的子树传播
|
|
710
|
+
const seqRef = useRef(parentSeq);
|
|
711
|
+
const downSeqRef = useRef(0);
|
|
712
|
+
useEffect(()=>{ if(seqRef.current===parentSeq) return; seqRef.current=parentSeq; if(loaded){ loadChildren(true); downSeqRef.current+=1; setDownSeq(downSeqRef.current); } }, [parentSeq, loaded, loadChildren]);
|
|
713
|
+
// 目录内发生变更(新建/上传/删除):已展开时 force 刷新本子树,再冒泡到根刷新。
|
|
714
|
+
const handleChanged = useCallback((changedDir?: string)=>{
|
|
715
|
+
if(changedDir && (changedDir === node.fullPath || changedDir.startsWith(node.fullPath + "/"))) loadChildren(true);
|
|
716
|
+
onChanged?.(changedDir);
|
|
717
|
+
}, [node.fullPath, loadChildren, onChanged]);
|
|
718
|
+
// 目录菜单「刷新」:只递归刷新本目录子树(自拉 + 向下传播),不冒泡到根、不闪全树
|
|
719
|
+
const handleRefreshTree = useCallback(()=>{
|
|
720
|
+
if(loaded){ loadChildren(true); downSeqRef.current+=1; setDownSeq(downSeqRef.current); }
|
|
721
|
+
else loadChildren(true);
|
|
722
|
+
}, [loaded, loadChildren]);
|
|
723
|
+
// 点击处理:点前半段(文件名附近)开文件/展开;点后部触发操作区(小屏无悬停等效)。
|
|
724
|
+
// 搜索结果里的命中目录同样展开(懒加载真实内容);要新 tab 打开用 hover 的「打开」按钮。
|
|
725
|
+
const handleClick = useCallback(()=>{ if(node.isDir){ const n=!open; onToggle(node.fullPath, n); if(n&&!loaded) loadChildren(); } else { onOpenFile(node.fullPath); } }, [node, open, loaded, loadChildren, onOpenFile, onToggle]);
|
|
726
|
+
const handleRowClick = useCallback((e: any)=>{
|
|
727
|
+
if(node.isDir) return; // 目录行不用
|
|
728
|
+
if(e.target.closest("[data-fileops]")) return; // 点操作区不冒泡
|
|
729
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
730
|
+
const relX = (e.clientX - rect.left) / rect.width;
|
|
731
|
+
// 点后 40% 等效悬停,显示操作区(大小屏一致)
|
|
732
|
+
if(relX>=0.6){ setHovered(true); return; }
|
|
733
|
+
// 点行其余区域 → 开文件
|
|
734
|
+
handleClick();
|
|
735
|
+
}, [node.isDir, handleClick]);
|
|
736
|
+
const norm = normSlash(node.fullPath)!;
|
|
737
|
+
const git = gitByPath.get(norm);
|
|
738
|
+
const containsGit = node.isDir && (git!==undefined || changedDirs.has(norm));
|
|
739
|
+
return (
|
|
740
|
+
<div key="row">
|
|
741
|
+
<div key="row-inner" data-file-node={node.fullPath} onClick={node.isDir?handleClick:handleRowClick} onMouseEnter={()=>setHovered(true)} onMouseLeave={()=>setHovered(false)}
|
|
742
|
+
style={{ position:"relative", display:"flex", alignItems:"center", gap:4, paddingLeft:8+depth*14, paddingRight:8, height:24, cursor:"pointer", background: node.fullPath===hlPath ? "rgba(79,142,247,0.18)" : (hovered?"var(--bg-hover)":"transparent"), borderRadius:4, userSelect:"none" }}>
|
|
743
|
+
{node.isDir ? (
|
|
744
|
+
<svg width={10} height={10} viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0, transform:open?"rotate(90deg)":"none", transition:"transform 0.1s"}}><polyline points="3 2 7 5 3 8"/></svg>
|
|
745
|
+
) : <span style={{width:10,flexShrink:0}} />}
|
|
746
|
+
<span style={{flexShrink:0, display:"flex", alignItems:"center"}}>{node.isDir?<FolderIcon open={open}/>:(<NodeIcon name={node.name}/>)}</span>
|
|
747
|
+
<span style={{fontSize:12, color: node.ignored ? "var(--text-dim, #777)" : "var(--text)", fontStyle: node.ignored ? "italic" : "normal", opacity: node.ignored ? 0.75 : 1, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", flex:1}} title={node.ignored ? `${node.fullPath}(${node.name === ".git" ? "git 仓库内部,默认隐藏" : "已被 .gitignore/.aitignore 忽略"})` : node.fullPath}>{node.name}</span>
|
|
748
|
+
{loading ? <svg width={10} height={10} viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth={2} strokeLinecap="round" style={{animation:"spin 0.8s linear infinite"}}><path d="M21 12a9 9 0 1 1-5.7-8.4"/></svg> : null}
|
|
749
|
+
{(!hovered && !node.isDir && git) ? <GitBadge status={git} t={tt} /> : null}
|
|
750
|
+
{(!hovered && containsGit) ? (
|
|
751
|
+
<span title={tt("files.containsChangedFiles")} aria-label={tt("files.containsChangedFiles")} style={{width:14,height:14,flexShrink:0,display:"flex",alignItems:"center",justifyContent:"center"}}>
|
|
752
|
+
<span style={{width:6,height:6,borderRadius:"50%",background:"#d6a84b"}} />
|
|
753
|
+
</span>
|
|
754
|
+
) : null}
|
|
755
|
+
{(node.isDir) ? <DirOpsMenu dir={node.fullPath} hovered={hovered} onChanged={handleChanged} onRefresh={handleRefreshTree} onOpenFile={onOpenFile} onOpenFolder={onOpenFolder} onAtMention={onAtMention} onTrash={onTrash} t={tt} root={root} cwd={cwd} /> : null}
|
|
756
|
+
{(node.isDir && hovered && onAtMention) ? (
|
|
757
|
+
<button onClick={e=>{e.stopPropagation(); onAtMention(relTo(node.fullPath,cwd),false);}} title={tt("files.insertPath")}
|
|
758
|
+
style={{position:"absolute",right:80,top:"50%",transform:"translateY(-50%)",display:"flex",alignItems:"center",justifyContent:"center",height:20,width:22,padding:0,background:"var(--bg-panel)",border:"1px solid var(--border)",borderRadius:4,color:"var(--accent)",cursor:"pointer",fontSize:11,fontWeight:700}}>@</button>
|
|
759
|
+
) : null}
|
|
760
|
+
{(node.isDir && hovered) ? (
|
|
761
|
+
<button onClick={e=>{e.stopPropagation(); openNewWindow("folder", node.fullPath);}} title={tt("files.openNewWindow")}
|
|
762
|
+
style={{position:"absolute",right:104,top:"50%",transform:"translateY(-50%)",display:"flex",alignItems:"center",justifyContent:"center",height:20,width:22,padding:0,background:"var(--bg-panel)",border:"1px solid var(--border)",borderRadius:4,color:"var(--accent)",cursor:"pointer",fontSize:11}}><NewWindowIcon/></button>
|
|
763
|
+
) : null}
|
|
764
|
+
{(node.isDir && hovered) ? (
|
|
765
|
+
<button onClick={e=>{e.stopPropagation(); onOpenFolder(node.fullPath, node.name);}} title={tt("files.openInPanel")}
|
|
766
|
+
style={{position:"absolute",right:32,top:"50%",transform:"translateY(-50%)",display:"flex",alignItems:"center",justifyContent:"center",height:20,padding:"0 8px",background:"var(--bg-panel)",border:"1px solid var(--border)",borderRadius:4,color:"var(--accent)",cursor:"pointer",fontSize:11,fontWeight:600,whiteSpace:"nowrap"}}>{tt("files.open")}</button>
|
|
767
|
+
) : null}
|
|
768
|
+
{(!node.isDir && (onAtMention||onTrash)) ? <FileOpsMenu filePath={node.fullPath} cwd={cwd} root={root} onOpenFile={onOpenFile} onOpenFolder={onOpenFolder} onAtMention={onAtMention} onTrash={onTrash} hovered={hovered} t={tt} onRefresh={handleChanged} /> : null}
|
|
769
|
+
</div>
|
|
770
|
+
{(node.isDir && open) ? (
|
|
771
|
+
<div key="tree">
|
|
772
|
+
{children.map((ch:any)=> <TreeNode key={ch.fullPath} node={ch} depth={depth+1} cwd={cwd} expanded={expanded} refreshSeq={downSeq} onToggle={onToggle} onOpenFile={onOpenFile} onOpenFolder={onOpenFolder} onAtMention={onAtMention} onTrash={onTrash} onChanged={onChanged} gitByPath={gitByPath} changedDirs={changedDirs} highlightPath={hlPath} t={tt} root={root} showIgnored={showIgnored} />)}
|
|
773
|
+
{(children.length===0 && loaded) ? <div key="empty" style={{paddingLeft:8+(depth+1)*14,fontSize:11,color:"var(--text-dim)",height:22,display:"flex",alignItems:"center"}}>{tt("files.noFiles")}</div> : null}
|
|
774
|
+
</div>
|
|
775
|
+
) : null}
|
|
776
|
+
</div>
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
export function FolderBrowser(props: any) {
|
|
782
|
+
const dense = props.dense === true; // sidebar 窄容器形态(FolderDockBlock):工具栏横向可滚
|
|
783
|
+
// 本 folder 的「显示忽略项」开关(单 folder 粒度;开启后请求带 showIgnored=1,忽略条目置灰显示)
|
|
784
|
+
const showIgnored = props.showIgnored === true;
|
|
785
|
+
// v3 dock 头单行化(方案 pi-web-dock-header-toolbar-plan §2.8):传 dockItemKey = 本实例
|
|
786
|
+
// 嵌入 dock 区块——工具条整行移除(按钮上提头行),命令/状态/菜单经 store 与 face 互通。
|
|
787
|
+
const dockItemKey: string | undefined = typeof props.dockItemKey === "string" && props.dockItemKey ? props.dockItemKey : undefined;
|
|
788
|
+
const dockHoist = Boolean(dockItemKey);
|
|
789
|
+
const root0: string = props.filePath; const cwd: string = props.cwd;
|
|
790
|
+
const onOpenFile = props.onOpenFile || (()=>{}); const onOpenFolder = props.onOpenFolder || (()=>{}); const onAtMention = props.onAtMention;
|
|
791
|
+
// 初始化:存活期内有快照 → 直接恢复展开状态与目录列表(切回零请求秒开,不闪 loading)
|
|
792
|
+
const restoredRef = loadExpandedSnapshot(root0);
|
|
793
|
+
const cachedRootsRef = peekEntries(root0, showIgnored);
|
|
794
|
+
const cachedListRef = peekList(root0, showIgnored);
|
|
795
|
+
const [root, setRoot] = useState(root0);
|
|
796
|
+
const [roots, setRoots] = useState<any[]>(cachedRootsRef ?? []);
|
|
797
|
+
// 可上溯的父目录(后端按允许 file roots 判定;null = 已在可浏览边界,隐藏「上一个目录」按钮)
|
|
798
|
+
const [parent, setParent] = useState<string | null>(cachedListRef?.parentPath ?? null);
|
|
799
|
+
const [loading, setLoading] = useState(!restoredRef); // 有缓存时不闪 loading
|
|
800
|
+
const [error, setError] = useState<string | null>(null);
|
|
801
|
+
const [expanded, setExpanded] = useState<Set<string>>(()=>restoredRef ?? new Set());
|
|
802
|
+
const [gitFiles, setGitFiles] = useState<any[]>([]);
|
|
803
|
+
const [gitStats, setGitStats] = useState({additions:0,deletions:0});
|
|
804
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
805
|
+
const [refreshDone, setRefreshDone] = useState(false);
|
|
806
|
+
const [changesOpen, setChangesOpen] = useState(false);
|
|
807
|
+
const [uploadBusy, setUploadBusy] = useState(false);
|
|
808
|
+
const [uploadMsg, setUploadMsg] = useState<any>(null);
|
|
809
|
+
const [plusOpen, setPlusOpen] = useState(false);
|
|
810
|
+
// dock 模式 + 按钮的头行锚点(face 经 request payload 传入;非 dock 模式用 plusRef 工具栏锚点)
|
|
811
|
+
const [plusAnchor, setPlusAnchor] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null);
|
|
812
|
+
const [newKind, setNewKind] = useState<string | null>(null); // "file" | "folder" | null
|
|
813
|
+
const [newName, setNewName] = useState("");
|
|
814
|
+
const [createBusy, setCreateBusy] = useState(false);
|
|
815
|
+
const [pendingTrash, setPendingTrash] = useState<any>(null); // { path, name, isDir } | null
|
|
816
|
+
const [trashBusy, setTrashBusy] = useState(false);
|
|
817
|
+
const [highlightPath, setHighlightPath] = useState<string | null>(null);
|
|
818
|
+
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
819
|
+
const dirUploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
820
|
+
const plusRef = useRef<HTMLDivElement | null>(null);
|
|
821
|
+
// + 菜单固定定位(2026-09):absolute 锚定会被两层 overflow 裁掉——dense 工具栏行自身
|
|
822
|
+
// overflowX:auto(CSS 规则另一轴同时变 auto = 滚动容器),外层 sidebar-container/
|
|
823
|
+
// StackCell/DockBody 全 hidden,菜单向下弹即被整段裁没(点了显示不出来)。改 fixed
|
|
824
|
+
// 锚定 + 按钮矩形:首帧离屏 → 测量 → 下开(底部装不下且上方更宽则上翻)→ 视口钳制
|
|
825
|
+
// (popup-anchor;transformed 祖先构成 fixed containing block 时按其原点校正)。
|
|
826
|
+
const plusMenuRef = useRef<HTMLDivElement | null>(null);
|
|
827
|
+
const [plusPos, setPlusPos] = useState<{ left: number; top: number } | null>(null);
|
|
828
|
+
useEffect(() => {
|
|
829
|
+
if (!plusOpen) return;
|
|
830
|
+
const menu = plusMenuRef.current;
|
|
831
|
+
if (!menu) return;
|
|
832
|
+
// dock 模式:锚点 = 头行按钮矩形(命令 payload 传入);非 dock:工具栏 + 按钮
|
|
833
|
+
const a = plusAnchor ?? (plusRef.current ? plusRef.current.getBoundingClientRect() : null);
|
|
834
|
+
if (!a) return;
|
|
835
|
+
const m = menu.getBoundingClientRect();
|
|
836
|
+
const gap = 4, pad = 8;
|
|
837
|
+
let top = a.bottom + gap;
|
|
838
|
+
const below = window.innerHeight - pad - top;
|
|
839
|
+
const above = a.top - gap - pad;
|
|
840
|
+
if (m.height > below && above > below) top = a.top - gap - m.height; // 上翻
|
|
841
|
+
const c = clampToViewport(a.left, top, m.width, m.height, pad);
|
|
842
|
+
const o = fixedOrigin(menu);
|
|
843
|
+
const next = { left: c.left - o.x, top: c.top - o.y };
|
|
844
|
+
setPlusPos((cur) => (cur && cur.left === next.left && cur.top === next.top) ? cur : next);
|
|
845
|
+
}, [plusOpen, newKind, plusAnchor]);
|
|
846
|
+
// fixed 菜单不随内容滚(工具栏横向滚/树滚 → 滞留旧位 = 错位),任意容器滚动即关。
|
|
847
|
+
useEffect(() => {
|
|
848
|
+
if (!plusOpen) return;
|
|
849
|
+
const onScroll = () => setPlusOpen(false);
|
|
850
|
+
window.addEventListener("scroll", onScroll, true);
|
|
851
|
+
return () => window.removeEventListener("scroll", onScroll, true);
|
|
852
|
+
}, [plusOpen]);
|
|
853
|
+
// ⋯ 更多菜单(目录级共享菜单:与文件顶栏 aiti-editor FileToolbar 的 ⋯ 集合对齐 + 目录特有项)
|
|
854
|
+
const [moreOpen, setMoreOpen] = useState(false);
|
|
855
|
+
const [morePos, setMorePos] = useState<{ left: number; top: number } | null>(null);
|
|
856
|
+
const [moreRename, setMoreRename] = useState(false);
|
|
857
|
+
const [moreRenameName, setMoreRenameName] = useState("");
|
|
858
|
+
const [moreBusy, setMoreBusy] = useState(false);
|
|
859
|
+
const [moreInfoOpen, setMoreInfoOpen] = useState(false);
|
|
860
|
+
// 根目录移动/复制弹窗:null=关,"move"/"copy"=弹窗模式(共用 TransferDialog)
|
|
861
|
+
const [moreTransfer, setMoreTransfer] = useState<null | "move" | "copy">(null);
|
|
862
|
+
const moreRef = useRef<HTMLDivElement | null>(null);
|
|
863
|
+
// ── 钉住 + 批量关闭(对齐 aiti-editor ⋯ 菜单 5 条;作用域=folder tab)──
|
|
864
|
+
// 钉住定义 = 区块清单 pin(一个定义一个展示,跨面同步);翻转后事件回灌驱动菜单 ✓ 刷新
|
|
865
|
+
const [, setUiTick] = useState(0);
|
|
866
|
+
// 当前 tab id:fileViewer props.filePath 是 tab 打开路径(内部切根不回写宿主)→ "folder:"+root0 恒可定位本 tab
|
|
867
|
+
const tabId = folderTabId(root0);
|
|
868
|
+
// dock 面板形态感知:非 closed(docked/floating)= 停靠/浮窗面(TabBar id = dock:<key>,
|
|
869
|
+
// 关闭须走状态机 closePanel);closed = 旧式独立 fileViewer folder tab(走 closeFileTabs)。
|
|
870
|
+
const panelKey = folderItemKey(root0);
|
|
871
|
+
const panelState = usePresent(panelKey);
|
|
872
|
+
const isDockPanel = panelState !== "closed";
|
|
873
|
+
const isTabPinned = () => isFolderPinned(root0);
|
|
874
|
+
const toggleTabPin = () => {
|
|
875
|
+
toggleFolderPin(root0);
|
|
876
|
+
setUiTick((n) => n + 1);
|
|
877
|
+
setUploadMsg({ ok: isFolderPinned(root0) ? t("files.pinDone") : t("files.unpinDone") });
|
|
878
|
+
};
|
|
879
|
+
// 关闭当前 folder 面板/tab(无脏状态,直接关;批量关闭见 batchCloseTabs)
|
|
880
|
+
const closeCurrentTab = () => { if (isDockPanel) closePanel(panelKey); else closeFileTabs([tabId]); };
|
|
881
|
+
// 批量关闭 folder tab:all/left/right;钉住的不关;当前 tab 不在批量范围(用「关闭」单独处理)。
|
|
882
|
+
// 文件 tab 不碰:脏确认归 aiti-editor(其批量关闭也跳过 folder tab,两类 tab 互不越界)
|
|
883
|
+
const batchCloseTabs = (mode: "all" | "left" | "right") => {
|
|
884
|
+
const tabs = getFileTabs().filter((x) => x.kind === "folder");
|
|
885
|
+
const activeIdx = tabs.findIndex((x) => x.id === tabId);
|
|
886
|
+
const targets = tabs.filter((x, i) => {
|
|
887
|
+
if (x.id === tabId) return false;
|
|
888
|
+
const p = x.id.startsWith("folder:") ? x.id.slice("folder:".length) : "";
|
|
889
|
+
if (x.pinned || (p && isFolderPinned(p))) return false; // 钉住不受批量关闭影响
|
|
890
|
+
if (mode === "all") return true;
|
|
891
|
+
if (mode === "left") return i < activeIdx;
|
|
892
|
+
if (mode === "right") return i > activeIdx;
|
|
893
|
+
return false;
|
|
894
|
+
});
|
|
895
|
+
if (targets.length === 0) return;
|
|
896
|
+
closeFileTabs(targets.map((x) => x.id));
|
|
897
|
+
setUploadMsg({ ok: t("files.batchClosed", { count: targets.length }) });
|
|
898
|
+
};
|
|
899
|
+
// ── 文件搜索(对齐 pi-web 原版 FileExplorer):/api/file-index 复用 @ mention 缓存索引,
|
|
900
|
+
// 结果 buildSearchTree 折叠成树(预展开含匹配的祖先目录;有查询时替换普通树)──
|
|
901
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
902
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
903
|
+
const [searchPaths, setSearchPaths] = useState<string[]>([]);
|
|
904
|
+
const [dirHits, setDirHits] = useState<Set<string>>(new Set()); // 命中目录(相对路径)——展开时懒加载真实全量内容,区别于纯祖先的过滤视图
|
|
905
|
+
const [searchLoading, setSearchLoading] = useState(false);
|
|
906
|
+
const [searchError, setSearchError] = useState(false);
|
|
907
|
+
const [searchExpanded, setSearchExpanded] = useState<Set<string>>(new Set());
|
|
908
|
+
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
|
909
|
+
const hasSearchQuery = searchQuery.trim().length > 0;
|
|
910
|
+
// ── 正文模式(P2 文档全文检索,复用 aiti-doc-center services.docs,经本扩展 server 代理)──
|
|
911
|
+
const [searchMode, setSearchMode] = useState<"files" | "fulltext">("files");
|
|
912
|
+
const [docItems, setDocItems] = useState<any[]>([]);
|
|
913
|
+
const [docsAvailable, setDocsAvailable] = useState<boolean | null>(null); // null=未知(页签默认显示;首次请求后由 server available 定)
|
|
914
|
+
const [fullScope, setFullScope] = useState(false); // 跳过当前 root 前缀过滤,看全部 workspace 的命中
|
|
915
|
+
// 最新 root/expanded 镜像:卸载/切根时写快照用
|
|
916
|
+
const stateRef = useRef({ root, expanded });
|
|
917
|
+
stateRef.current = { root, expanded };
|
|
918
|
+
useEffect(() => () => saveExpandedSnapshot(stateRef.current.root, stateRef.current.expanded), []);
|
|
919
|
+
useEffect(()=>{ setRoot(root0); }, [root0]);
|
|
920
|
+
// 切换目录/工作区:搜索展开集按绝对路径存,旧 root 的集合全部失效,清空(输入保留,防抖请求按新 root 自动重发)
|
|
921
|
+
useEffect(()=>{ setSearchExpanded(new Set()); setDirHits(new Set()); setDocItems([]); }, [root0]);
|
|
922
|
+
// 搜索请求(文件模式):150ms 防抖 + AbortController(仅输入变化竞态取消;切目录立即重搜)。
|
|
923
|
+
// 文件与目录条目都保留——目录命中可展开查看真实全量内容。
|
|
924
|
+
useEffect(() => {
|
|
925
|
+
if (!searchOpen || searchMode !== "files") return;
|
|
926
|
+
const query = searchQuery.trim();
|
|
927
|
+
if (!query) { setSearchPaths([]); setDirHits(new Set()); setSearchLoading(false); setSearchError(false); return; }
|
|
928
|
+
const controller = new AbortController();
|
|
929
|
+
setSearchLoading(true); setSearchError(false);
|
|
930
|
+
const timer = setTimeout(() => {
|
|
931
|
+
// POST body 传 cwd/q:绝对路径不进 URL(访问日志/代理日志安全)
|
|
932
|
+
fetch(apiUrl("/api/file-index"), {
|
|
933
|
+
method: "POST",
|
|
934
|
+
headers: { "content-type": "application/json" },
|
|
935
|
+
body: JSON.stringify({ cwd: root, q: query }),
|
|
936
|
+
signal: controller.signal,
|
|
937
|
+
})
|
|
938
|
+
.then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
|
|
939
|
+
.then((d: any) => {
|
|
940
|
+
const ms = (d.matches || []) as any[]; // 文件与目录都保留(目录条目服务端已参与评分 +10)
|
|
941
|
+
setSearchPaths(ms.map((e: any) => e.path as string));
|
|
942
|
+
setDirHits(new Set(ms.filter((e: any) => e.isDir).map((e: any) => e.path as string)));
|
|
943
|
+
})
|
|
944
|
+
.catch(() => { if (!controller.signal.aborted) { setSearchPaths([]); setSearchError(true); } })
|
|
945
|
+
.finally(() => { if (!controller.signal.aborted) setSearchLoading(false); });
|
|
946
|
+
}, 150);
|
|
947
|
+
return () => { clearTimeout(timer); controller.abort(); };
|
|
948
|
+
}, [root, searchOpen, searchMode, searchQuery]);
|
|
949
|
+
// 搜索请求(正文模式):同款防抖,走本扩展 server 代理(server 进程内消费 doc-center services.docs)。
|
|
950
|
+
// server 拉全量(limit 200),当前 root 前缀过滤在客户端做——doc-center 的 under 按「收集时 root」前缀匹配,
|
|
951
|
+
// 子目录 root 下搜不到父 workspace 收集的文档(方案 §5.2)。
|
|
952
|
+
useEffect(() => {
|
|
953
|
+
if (!searchOpen || searchMode !== "fulltext") return;
|
|
954
|
+
const query = searchQuery.trim();
|
|
955
|
+
if (!query) { setDocItems([]); setSearchLoading(false); setSearchError(false); return; }
|
|
956
|
+
const controller = new AbortController();
|
|
957
|
+
setSearchLoading(true); setSearchError(false);
|
|
958
|
+
const timer = setTimeout(() => {
|
|
959
|
+
fetch(apiUrl("/api/extensions/aiti-folder/doc-search"), {
|
|
960
|
+
method: "POST",
|
|
961
|
+
headers: { "content-type": "application/json" },
|
|
962
|
+
body: JSON.stringify({ q: query, limit: 200 }),
|
|
963
|
+
signal: controller.signal,
|
|
964
|
+
})
|
|
965
|
+
.then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
|
|
966
|
+
.then((d: any) => {
|
|
967
|
+
if (d.available !== true) {
|
|
968
|
+
// doc-center 未装/未启用:隐藏「正文」页签、回落文件模式、通知一次
|
|
969
|
+
setDocsAvailable(false);
|
|
970
|
+
setSearchMode("files");
|
|
971
|
+
setDocItems([]);
|
|
972
|
+
setUploadMsg({ err: t("sidebar.docCenterUnavailable") });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
setDocsAvailable(true);
|
|
976
|
+
setDocItems(Array.isArray(d.items) ? d.items : []);
|
|
977
|
+
})
|
|
978
|
+
.catch(() => { if (!controller.signal.aborted) { setDocItems([]); setSearchError(true); } })
|
|
979
|
+
.finally(() => { if (!controller.signal.aborted) setSearchLoading(false); });
|
|
980
|
+
}, 150);
|
|
981
|
+
return () => { clearTimeout(timer); controller.abort(); };
|
|
982
|
+
}, [root, searchOpen, searchMode, searchQuery]);
|
|
983
|
+
// 打开搜索条时自动聚焦输入框
|
|
984
|
+
useEffect(() => { if (searchOpen) searchInputRef.current?.focus(); }, [searchOpen]);
|
|
985
|
+
// 结果渲染成树:含匹配文件的祖先目录自动展开(只增不删,保留用户手动折叠)
|
|
986
|
+
useEffect(() => {
|
|
987
|
+
if (searchPaths.length === 0) return;
|
|
988
|
+
const dirs = new Set<string>();
|
|
989
|
+
for (const relative of searchPaths) {
|
|
990
|
+
const parts = relative.split("/");
|
|
991
|
+
let path = "";
|
|
992
|
+
for (let i = 0; i < parts.length - 1; i++) { path = path ? path + "/" + parts[i] : parts[i]; dirs.add(joinPath(root, path)!); }
|
|
993
|
+
}
|
|
994
|
+
setSearchExpanded(prev => {
|
|
995
|
+
let changed = false; const next = new Set(prev);
|
|
996
|
+
for (const dir of dirs) if (!next.has(dir)) { next.add(dir); changed = true; }
|
|
997
|
+
return changed ? next : prev;
|
|
998
|
+
});
|
|
999
|
+
}, [root, searchPaths]);
|
|
1000
|
+
const searchRoots = useMemo(() => {
|
|
1001
|
+
// 命中目录(即使无子命中、树角色为叶子)也纠正为目录节点;
|
|
1002
|
+
// 展开时懒加载真实全量内容(不过滤);纯祖先目录保持预填充命中子树(过滤视图)
|
|
1003
|
+
const toNode = (n: SearchTreeNode): any => {
|
|
1004
|
+
const isHitDir = dirHits.has(n.path);
|
|
1005
|
+
return { name: n.name, fullPath: joinPath(root, n.path), isDir: n.isDir || isHitDir, size: 0, children: isHitDir ? [] : n.children.map(toNode), loaded: !isHitDir };
|
|
1006
|
+
};
|
|
1007
|
+
return buildSearchTree(searchPaths).map(toNode);
|
|
1008
|
+
}, [root, searchPaths, dirHits]);
|
|
1009
|
+
// 开/关搜索:关闭时清空输入与结果、模式复位文件(下次打开是干净状态)
|
|
1010
|
+
const toggleSearch = useCallback(() => {
|
|
1011
|
+
if (searchOpen) { setSearchOpen(false); setSearchQuery(""); setSearchPaths([]); setDirHits(new Set()); setDocItems([]); setSearchError(false); setSearchLoading(false); setSearchExpanded(new Set()); setSearchMode("files"); }
|
|
1012
|
+
else setSearchOpen(true);
|
|
1013
|
+
}, [searchOpen]);
|
|
1014
|
+
// 切换文件/正文模式:清两模式各自结果(输入保留,防抖请求按新模式自动重发)
|
|
1015
|
+
const switchSearchMode = useCallback((m: "files" | "fulltext") => {
|
|
1016
|
+
if (m === searchMode) return;
|
|
1017
|
+
setSearchMode(m); setSearchPaths([]); setDirHits(new Set()); setDocItems([]); setSearchError(false); setSearchLoading(false);
|
|
1018
|
+
}, [searchMode]);
|
|
1019
|
+
// 正文模式可见集:默认限当前 root 前缀(doc-center 的 under 按收集时 root 前缀匹配,子目录 root 下搜不到父 workspace 的文档,故在客户端过滤);fullScope 开关看全部
|
|
1020
|
+
const visibleDocs = useMemo(() => {
|
|
1021
|
+
if (fullScope) return docItems;
|
|
1022
|
+
const prefix = (root || "").replace(/\/+$/, "") + "/";
|
|
1023
|
+
return docItems.filter(d => typeof d.path === "string" && d.path.startsWith(prefix));
|
|
1024
|
+
}, [docItems, fullScope, root]);
|
|
1025
|
+
// 「在目录打开」:AppShell 派发 pi-web:folder-reveal → 在 root 内展开到目标并高亮滚动;
|
|
1026
|
+
// 新开 tab 时事件可能早于本组件挂载,挂载时从 window.__piFolderRevealLast 重放(10s 内)。
|
|
1027
|
+
const revealPath = useCallback((rootPath: string, target: string)=>{
|
|
1028
|
+
if(!(target === rootPath || target.startsWith(rootPath + "/"))) return;
|
|
1029
|
+
const chain = [target];
|
|
1030
|
+
let cur: string | null = target === rootPath ? null : dirOf(target);
|
|
1031
|
+
for(let i=0; i<64 && cur; i++){
|
|
1032
|
+
if(cur === rootPath){ chain.push(cur); break; }
|
|
1033
|
+
if(!(cur.startsWith(rootPath + "/"))) break;
|
|
1034
|
+
chain.push(cur);
|
|
1035
|
+
const p = dirOf(cur);
|
|
1036
|
+
if(!p || p === cur) break;
|
|
1037
|
+
cur = p;
|
|
1038
|
+
}
|
|
1039
|
+
setExpanded(prev => new Set([...prev, ...chain]));
|
|
1040
|
+
setHighlightPath(target);
|
|
1041
|
+
const started = Date.now();
|
|
1042
|
+
const poll = ()=>{
|
|
1043
|
+
const el = [...document.querySelectorAll("[data-file-node]")].find(n => n.getAttribute("data-file-node") === target);
|
|
1044
|
+
if(el){ el.scrollIntoView({ block:"center", behavior:"smooth" }); return; }
|
|
1045
|
+
if(Date.now() - started < 3000) setTimeout(poll, 120);
|
|
1046
|
+
};
|
|
1047
|
+
setTimeout(poll, 60);
|
|
1048
|
+
}, []);
|
|
1049
|
+
useEffect(()=>{
|
|
1050
|
+
const onReveal = (e: any)=>{ const d = e.detail; if(d && d.root && d.target) revealPath(d.root, d.target); };
|
|
1051
|
+
window.addEventListener("pi-web:folder-reveal", onReveal as EventListener);
|
|
1052
|
+
const last = (window as any).__piFolderRevealLast;
|
|
1053
|
+
if(last && Date.now() - last.ts < 10000 && last.root === root) revealPath(last.root, last.target);
|
|
1054
|
+
return ()=>window.removeEventListener("pi-web:folder-reveal", onReveal as EventListener);
|
|
1055
|
+
}, [root, revealPath]);
|
|
1056
|
+
// + 菜单点外部关闭
|
|
1057
|
+
useEffect(()=>{
|
|
1058
|
+
if(!plusOpen) return;
|
|
1059
|
+
const onDown = (e: any)=>{ if(plusRef.current && !plusRef.current.contains(e.target)) setPlusOpen(false); };
|
|
1060
|
+
document.addEventListener("mousedown", onDown);
|
|
1061
|
+
return ()=>document.removeEventListener("mousedown", onDown);
|
|
1062
|
+
}, [plusOpen]);
|
|
1063
|
+
// ⋯ 更多菜单点外部关闭 / 滚动关闭(fixed 定位弹层不随头滚动)
|
|
1064
|
+
useEffect(()=>{
|
|
1065
|
+
if(!moreOpen) return;
|
|
1066
|
+
const onDown = (e: any)=>{ if(moreRef.current && !moreRef.current.contains(e.target)) setMoreOpen(false); };
|
|
1067
|
+
const onWheel = ()=>setMoreOpen(false);
|
|
1068
|
+
document.addEventListener("mousedown", onDown);
|
|
1069
|
+
document.addEventListener("wheel", onWheel, { passive: true } as any);
|
|
1070
|
+
return ()=>{ document.removeEventListener("mousedown", onDown); document.removeEventListener("wheel", onWheel); };
|
|
1071
|
+
}, [moreOpen]);
|
|
1072
|
+
const toggleMore = useCallback((e: any)=>{
|
|
1073
|
+
if(moreOpen){ setMoreOpen(false); return; }
|
|
1074
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
1075
|
+
// 菜单固定视口定位(同 DirOpsMenu):left 夹在视口内;底部空间不足时向上翻转。
|
|
1076
|
+
// 估算菜单高度:~17 项 + 7 分区头 + 7 分隔线 + 关闭图标排 + padding + gap ≈ 620,用 640 留 buffer;上下都不够时贴顶。
|
|
1077
|
+
const mh = 640;
|
|
1078
|
+
const menuWidth = 210;
|
|
1079
|
+
const left = Math.max(8, Math.min(r.right - menuWidth, window.innerWidth - menuWidth - 8));
|
|
1080
|
+
const overflowDown = r.bottom + 4 + mh > window.innerHeight;
|
|
1081
|
+
const overflowUp = r.top - mh - 4 < 0;
|
|
1082
|
+
let top: number;
|
|
1083
|
+
if (overflowDown && !overflowUp) {
|
|
1084
|
+
top = Math.max(8, r.top - mh - 4);
|
|
1085
|
+
} else if (overflowDown && overflowUp) {
|
|
1086
|
+
top = 8;
|
|
1087
|
+
} else {
|
|
1088
|
+
top = r.bottom + 4;
|
|
1089
|
+
}
|
|
1090
|
+
setMorePos({ left, top });
|
|
1091
|
+
setMoreOpen(true);
|
|
1092
|
+
}, [moreOpen]);
|
|
1093
|
+
// 复制路径(对齐文件顶栏「复制路径」;结果走头部通知位,失败显错误)
|
|
1094
|
+
const doCopyRoot = useCallback(()=>{
|
|
1095
|
+
copyTextToClipboard(root).then(
|
|
1096
|
+
()=>{ setUploadMsg({ ok: t("files.copyDone") }); setMoreOpen(false); },
|
|
1097
|
+
()=>{ setUploadMsg({ err: t("files.copyFailed") }); setMoreOpen(false); }
|
|
1098
|
+
);
|
|
1099
|
+
}, [root]);
|
|
1100
|
+
// 重命名根目录:成功后打开新路径(tab 跟随新名;与子目录重命名行为一致,旧 tab 不强制迁移)
|
|
1101
|
+
const doRenameRoot = useCallback(async ()=>{
|
|
1102
|
+
const n = moreRenameName.trim();
|
|
1103
|
+
if(!n || !moreRename || moreBusy) return;
|
|
1104
|
+
setMoreBusy(true);
|
|
1105
|
+
try{
|
|
1106
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/rename"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ path: root, newName: n }) });
|
|
1107
|
+
const d = await r.json().catch(()=>({}));
|
|
1108
|
+
if(!r.ok || !d.ok){ setUploadMsg({ err: d.error || t("files.renameFailed") }); return; }
|
|
1109
|
+
const parent = root.slice(0, root.lastIndexOf("/")) || "/";
|
|
1110
|
+
setMoreOpen(false); setMoreRename(false); setMoreRenameName("");
|
|
1111
|
+
setUploadMsg({ ok: t("files.renameSuccess") });
|
|
1112
|
+
onOpenFolder(parent + "/" + n, n);
|
|
1113
|
+
}catch(e:any){ setUploadMsg({ err: e && e.message ? e.message : String(e) }); }
|
|
1114
|
+
finally{ setMoreBusy(false); }
|
|
1115
|
+
}, [root, moreRename, moreRenameName, moreBusy, onOpenFolder]);
|
|
1116
|
+
// @ 到新 session(目录引用带尾斜杠;【全路径】——新会话 cwd 可能不同,相对引用不可靠;
|
|
1117
|
+
// relTo(root,cwd) 在 root==cwd 时返回空串→"@/ "丢路径,2026-09-08 修。同 DirOpsMenu)
|
|
1118
|
+
// 不随便切 CWD:目标目录在当前 CWD/关注路径内 → 保持(doc-center 同款判定)
|
|
1119
|
+
const doRootAtNew = useCallback(async ()=>{
|
|
1120
|
+
try{
|
|
1121
|
+
const p = root + "/";
|
|
1122
|
+
const text = buildAtText(p);
|
|
1123
|
+
await openNewSessionWithExtSvc({ text, targetDir: root, targetPath: root, currentCwd: cwd, apiUrl, extSvc });
|
|
1124
|
+
}catch{/* noop */}
|
|
1125
|
+
setMoreOpen(false);
|
|
1126
|
+
}, [root, cwd]);
|
|
1127
|
+
// 打开上级目录并定位本目录(对齐文件顶栏「在目录打开」的 openFolder + folder-reveal 链路)
|
|
1128
|
+
const doOpenParent = useCallback(()=>{
|
|
1129
|
+
const parent = root.slice(0, root.lastIndexOf("/")) || "/";
|
|
1130
|
+
setMoreOpen(false);
|
|
1131
|
+
if(parent === root) return;
|
|
1132
|
+
if(onOpenFolder) onOpenFolder(parent, lastSeg(parent) || undefined);
|
|
1133
|
+
try{
|
|
1134
|
+
const detail = { root: parent, target: root };
|
|
1135
|
+
window.dispatchEvent(new CustomEvent("pi-web:folder-reveal", { detail }));
|
|
1136
|
+
(window as any).__piFolderRevealLast = { ...detail, ts: Date.now() };
|
|
1137
|
+
}catch{/* noop */}
|
|
1138
|
+
}, [root, onOpenFolder]);
|
|
1139
|
+
// 下载根目录打包(同 DirOpsMenu:票据两步下载,路径不进 URL)
|
|
1140
|
+
const doDownloadRoot = useCallback(async ()=>{
|
|
1141
|
+
setMoreOpen(false);
|
|
1142
|
+
try{
|
|
1143
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/archive-ticket"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ path: root }) });
|
|
1144
|
+
const d = await r.json().catch(()=>({} as any));
|
|
1145
|
+
if(!r.ok || !d.ok || !d.ticket){ setUploadMsg({ err: d.error || ("HTTP "+r.status) }); return; }
|
|
1146
|
+
const a = document.createElement("a"); a.href = apiUrl("/api/extensions/aiti-folder/archive?ticket="+encodeURIComponent(d.ticket)); a.download = lastSeg(root)+".tar.gz"; a.click();
|
|
1147
|
+
}catch{/* noop */}
|
|
1148
|
+
}, [root]);
|
|
1149
|
+
useEffect(()=>{ let c=false;
|
|
1150
|
+
const cached = refreshKey === 0 ? peekList(root, showIgnored) : null;
|
|
1151
|
+
if (cached) { setRoots(cached.entries); setParent(cached.parentPath ?? null); setError(null); setLoading(false); return; }
|
|
1152
|
+
setLoading(true); setError(null);
|
|
1153
|
+
fetchList(apiUrl, root, { force: true, showIgnored }).then(l=>{ if(!c){ setRoots(l.entries); setParent(l.parentPath ?? null); } }).catch(e=>{ if(!c) setError(e&&e.message?e.message:String(e)); }).finally(()=>{ if(!c) setLoading(false); });
|
|
1154
|
+
return ()=>{ c=true; };
|
|
1155
|
+
}, [root, refreshKey, showIgnored]);
|
|
1156
|
+
// 「显示忽略项」切换 → 强刷根(默认列表不含 ignored 条目,开启后需重拉带标记列表);
|
|
1157
|
+
// ref 防首挂误刷(初始值相等跳过)
|
|
1158
|
+
const showIgnoredPrevRef = useRef(showIgnored);
|
|
1159
|
+
useEffect(()=>{ if(showIgnoredPrevRef.current===showIgnored) return; showIgnoredPrevRef.current=showIgnored; setRefreshKey(k=>k+1); }, [showIgnored]);
|
|
1160
|
+
// git 工具面(git-ops M3):引擎在位探测 + refs + `aiti-git-ops:changed:*` topic 订阅;
|
|
1161
|
+
// statusBump 追加进 /api/git/status 拉取依赖——引擎写操作/checkout 后树徽标即时刷新(零轮询)。
|
|
1162
|
+
const folderGit = useFolderGit(root);
|
|
1163
|
+
useEffect(()=>{ let c=false; fetch(apiUrl("/api/git/status"),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({cwd:root})}).then(r=>r.json()).then(st=>{ if(!c){ setGitFiles(st.isGitRepository?st.files:[]); setGitStats(st.isGitRepository?{additions:st.additions||0,deletions:st.deletions||0}:{additions:0,deletions:0}); } }).catch(()=>{ if(!c){ setGitFiles([]); setGitStats({additions:0,deletions:0}); } }); return ()=>{c=true;}; }, [root, refreshKey, props.gitRefreshKey, folderGit.statusBump]);
|
|
1164
|
+
const gitByPath = useMemo(()=>buildGitByPath(gitFiles), [gitFiles]);
|
|
1165
|
+
const changedDirs = useMemo(()=>buildChangedDirs(gitFiles, root), [gitFiles, root]);
|
|
1166
|
+
const toggle = useCallback((fp: string,o: boolean)=>{ setExpanded(p=>{ const n=new Set(p); if(o) n.add(fp); else n.delete(fp); return n; }); }, []);
|
|
1167
|
+
const doRefresh = useCallback(()=>{ setRefreshKey(k=>k+1); setRefreshDone(true); setTimeout(()=>setRefreshDone(false), 1800); }, []);
|
|
1168
|
+
const handleUploadInput = useCallback(async (ev: any)=>{
|
|
1169
|
+
const files = Array.from(ev.target.files||[]); ev.target.value="";
|
|
1170
|
+
if(files.length===0||uploadBusy) return;
|
|
1171
|
+
setUploadBusy(true); setUploadMsg(null);
|
|
1172
|
+
try{
|
|
1173
|
+
const fd = new FormData(); fd.append("directory", root); (files as any[]).forEach(f=>fd.append("files", f, f.name));
|
|
1174
|
+
const r = await fetch(apiUrl("/api/files/upload?conflict=skip"), { method:"POST", body:fd });
|
|
1175
|
+
const d = await r.json().catch(()=>({}));
|
|
1176
|
+
if(!r.ok && !d.conflicts) { setUploadMsg({err:d.error||("HTTP "+r.status)}); }
|
|
1177
|
+
else { setUploadMsg({ up:(d.uploaded||[]).length, skip:(d.skipped||[]).length, err: (d.errors||[]).length }); setRefreshKey(k=>k+1); }
|
|
1178
|
+
}catch(e:any){ setUploadMsg({err:e&&e.message?e.message:String(e)}); }
|
|
1179
|
+
finally{ setUploadBusy(false); }
|
|
1180
|
+
}, [root, uploadBusy]);
|
|
1181
|
+
// 上传文件夹:webkitdirectory 多选,按相对路径(含顶层文件夹名)整体上传到 root 下
|
|
1182
|
+
const handleDirUploadInput = useCallback(async (ev: any)=>{
|
|
1183
|
+
const files = Array.from(ev.target.files||[]); ev.target.value="";
|
|
1184
|
+
if(files.length===0||uploadBusy) return;
|
|
1185
|
+
setUploadBusy(true); setUploadMsg(null);
|
|
1186
|
+
try{
|
|
1187
|
+
const fd = new FormData();
|
|
1188
|
+
fd.append("dir", root);
|
|
1189
|
+
for (const f of files as any[]) {
|
|
1190
|
+
const rel = (f.webkitRelativePath||f.name||"").replace(/\\/g,"/");
|
|
1191
|
+
fd.append("files", f, rel || f.name);
|
|
1192
|
+
}
|
|
1193
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/upload-tree"), { method:"POST", body:fd });
|
|
1194
|
+
const d = await r.json().catch(()=>({}));
|
|
1195
|
+
if(!r.ok || !d.ok) { setUploadMsg({err:d.error||("HTTP "+r.status)}); }
|
|
1196
|
+
else { setUploadMsg({ up:(d.uploaded||[]).length, skip:(d.skipped||[]).length, err:(d.errors||[]).length }); setRefreshKey(k=>k+1); }
|
|
1197
|
+
}catch(e:any){ setUploadMsg({err:e&&e.message?e.message:String(e)}); }
|
|
1198
|
+
finally{ setUploadBusy(false); }
|
|
1199
|
+
}, [root, uploadBusy]);
|
|
1200
|
+
// 删除 → 移入系统回收站(可恢复)
|
|
1201
|
+
const handleTrash = useCallback(async ()=>{
|
|
1202
|
+
if(!pendingTrash || trashBusy) return;
|
|
1203
|
+
setTrashBusy(true);
|
|
1204
|
+
try{
|
|
1205
|
+
const r = await fetch(apiUrl("/api/extensions/aiti-folder/trash"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ paths: [pendingTrash.path] }) });
|
|
1206
|
+
const d = await r.json().catch(()=>({}));
|
|
1207
|
+
if(!r.ok || !d.ok) { setUploadMsg({err:d.error||("HTTP "+r.status)}); }
|
|
1208
|
+
else if (d.errors && d.errors.length) { setUploadMsg({err:(d.errors[0].error)||t("files.trashFailed")}); }
|
|
1209
|
+
else {
|
|
1210
|
+
setUploadMsg({ ok: t("files.trashDone")+" ✓" });
|
|
1211
|
+
setRefreshKey(k=>k+1);
|
|
1212
|
+
if(pendingTrash.isDir) setExpanded(p=>{ const n=new Set(p); n.delete(pendingTrash.path); return n; });
|
|
1213
|
+
}
|
|
1214
|
+
setPendingTrash(null);
|
|
1215
|
+
}catch(e:any){ setUploadMsg({err:e&&e.message?e.message:String(e)}); setPendingTrash(null); }
|
|
1216
|
+
finally{ setTrashBusy(false); }
|
|
1217
|
+
}, [pendingTrash, trashBusy]);
|
|
1218
|
+
const handleCreate = useCallback(async ()=>{
|
|
1219
|
+
const name = newName.trim();
|
|
1220
|
+
if(!name||!newKind||createBusy) return;
|
|
1221
|
+
const endpoint = newKind==="file" ? "/api/extensions/aiti-folder/create-file" : "/api/extensions/aiti-folder/create-folder";
|
|
1222
|
+
setCreateBusy(true);
|
|
1223
|
+
try{
|
|
1224
|
+
const r = await fetch(apiUrl(endpoint), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ dir: root, name }) });
|
|
1225
|
+
const d = await r.json().catch(()=>({}));
|
|
1226
|
+
if(!r.ok || !d.ok) { setUploadMsg({err:d.error||("HTTP "+r.status)}); }
|
|
1227
|
+
else {
|
|
1228
|
+
setUploadMsg({ ok:(newKind==="file"?t("files.newFile"):t("files.newFolder"))+" ✓" });
|
|
1229
|
+
setNewKind(null); setNewName(""); setPlusOpen(false);
|
|
1230
|
+
setRefreshKey(k=>k+1);
|
|
1231
|
+
if(newKind==="file" && d.path) onOpenFile(d.path);
|
|
1232
|
+
}
|
|
1233
|
+
}catch(e:any){ setUploadMsg({err:e&&e.message?e.message:String(e)}); }
|
|
1234
|
+
finally{ setCreateBusy(false); }
|
|
1235
|
+
}, [newKind, newName, root, onOpenFile, createBusy]);
|
|
1236
|
+
/* ── dock 头命令 / 状态 / 菜单注册(v3;仅 dock 模式)──────────────────────────
|
|
1237
|
+
* face 按钮经 requestDockCommand 进来;未挂载时 store 先展开区块并排队,挂载后补跑。
|
|
1238
|
+
* 菜单项 = 原工具栏「⋮ 更多」全量并入 dock 头 ⋮ 的「文件操作」段。 */
|
|
1239
|
+
const buildDockMenuItems = (): DockMenuItem[] => {
|
|
1240
|
+
const items: DockMenuItem[] = [];
|
|
1241
|
+
const push = (id: string, label: string, run: () => void, icon?: any) => items.push({ kind: "item", id, label, icon, run });
|
|
1242
|
+
// 菜单分组重排(2026-09-16,与工具栏 ⋮ 同序):导航 → 导出 → 路径引用 → 打开/查看 → 整理 → Git
|
|
1243
|
+
items.push({ kind: "head", label: t("files.menuNav") });
|
|
1244
|
+
push("op", t("files.openParentDir"), () => doOpenParent(), <MiArrowUp/>);
|
|
1245
|
+
items.push({ kind: "sep" });
|
|
1246
|
+
items.push({ kind: "head", label: t("files.menuExport") });
|
|
1247
|
+
push("dl", t("files.download"), () => { void doDownloadRoot(); }, <MiDownload/>);
|
|
1248
|
+
items.push({ kind: "sep" });
|
|
1249
|
+
items.push({ kind: "head", label: t("files.menuRef") });
|
|
1250
|
+
push("cp", t("files.copyPath"), () => { void doCopyRoot(); }, <MiCopy/>);
|
|
1251
|
+
push("atn", t("files.atNewSession"), () => { void doRootAtNew(); }, <MiAt/>);
|
|
1252
|
+
items.push({ kind: "sep" });
|
|
1253
|
+
items.push({ kind: "head", label: t("files.menuOpen") });
|
|
1254
|
+
push("rf", t("files.refresh"), () => doRefresh(), <MiRefresh/>);
|
|
1255
|
+
push("nw", t("files.openNewWindow"), () => openNewWindow("folder", root), <NewWindowIcon/>);
|
|
1256
|
+
push("ot", t("files.openTerminal"), () => { void openTerminal(root).then((err) => { if (err) setUploadMsg({ err }); }); }, <TerminalIcon/>);
|
|
1257
|
+
push("info", t("files.entryInfo"), () => setMoreInfoOpen(true), <MiInfo/>);
|
|
1258
|
+
items.push({ kind: "sep" });
|
|
1259
|
+
items.push({ kind: "head", label: t("files.menuOrganize") });
|
|
1260
|
+
push("rn", t("files.rename"), () => { setMoreRename(true); setMoreRenameName(lastSeg(root) || ""); }, <MiPencil/>);
|
|
1261
|
+
push("mv", t("files.move"), () => { setMoreTransfer("move"); }, <MiMove/>);
|
|
1262
|
+
push("cpto", t("files.copyTo"), () => { setMoreTransfer("copy"); }, <MiCopyTo/>);
|
|
1263
|
+
items.push({ kind: "sep" });
|
|
1264
|
+
items.push({ kind: "head", label: t("files.menuGit") });
|
|
1265
|
+
push("gr", "Git Review", () => { try { grOpen() && grOpen({ scope: { kind: "directory", path: root } }); grWork() && grWork(); } catch (_) { /* noop */ } }, <GrEyeIcon size={13}/>);
|
|
1266
|
+
// ⚠ 标签页菜单区(menuTabs:钉住/关闭/关闭左/右/全部)不在此注册——dock 头 ⋮ 只在
|
|
1267
|
+
// 左栏区块形态渲染(FolderDockBlock 传 dockItemKey),区块形态的钉住/关闭由 DockBody
|
|
1268
|
+
// 区块操作区统一提供(extraMenuItems「钉住」=清单 pin ✓ / 「关闭」=关面板移清单);
|
|
1269
|
+
// 标签页菜单区仅属于右侧 dock tab / 浮窗形态(工具栏 ⋮ 更多菜单,非 dock 头)。
|
|
1270
|
+
return items;
|
|
1271
|
+
};
|
|
1272
|
+
useDockCommands(dockItemKey, dockItemKey ? {
|
|
1273
|
+
search: () => { try { writeBlockCollapsed(dockItemKey, false); } catch { /* ignore */ } toggleSearch(); },
|
|
1274
|
+
terminal: () => { void openTerminal(root).then((err) => { if (err) setUploadMsg({ err }); }); },
|
|
1275
|
+
refresh: () => doRefresh(),
|
|
1276
|
+
changes: () => setChangesOpen((v) => !v),
|
|
1277
|
+
at: () => { try { if (onAtMention) onAtMention(relTo(root, cwd || root), false); } catch { /* ignore */ } },
|
|
1278
|
+
plus: (payload?: unknown) => {
|
|
1279
|
+
const a = payload && typeof payload === "object" ? (payload as any).anchor : null;
|
|
1280
|
+
setPlusAnchor(a && typeof a.left === "number" ? a : null);
|
|
1281
|
+
setPlusPos(null);
|
|
1282
|
+
setPlusOpen(true);
|
|
1283
|
+
},
|
|
1284
|
+
setRoot: (payload?: unknown) => {
|
|
1285
|
+
const p = typeof payload === "string" ? payload : "";
|
|
1286
|
+
if (!p || p === root) return;
|
|
1287
|
+
saveExpandedSnapshot(root, expanded);
|
|
1288
|
+
setParent(null);
|
|
1289
|
+
setRoot(p);
|
|
1290
|
+
setExpanded(loadExpandedSnapshot(p) ?? new Set());
|
|
1291
|
+
},
|
|
1292
|
+
parent: () => doOpenParent(),
|
|
1293
|
+
} : null);
|
|
1294
|
+
useDockHeaderState(dockItemKey, dockItemKey ? {
|
|
1295
|
+
searchOpen, refreshDone, root, gitCount: gitFiles.length, changesOpen, hasParent: parent != null,
|
|
1296
|
+
} : null);
|
|
1297
|
+
useDockHeaderMenuItems(dockItemKey, dockItemKey ? buildDockMenuItems() : null);
|
|
1298
|
+
|
|
1299
|
+
return (
|
|
1300
|
+
<div style={{ height:"100%", display:"flex", flexDirection:"column", background:"var(--bg)" }}>
|
|
1301
|
+
{/* 头部一行(仅非 dock 模式):v3 嵌入 dock 区块时整行移除,按钮上提 dock 头(§2.8) */}
|
|
1302
|
+
{!dockHoist && (<div style={{ display:"flex", alignItems:"center", gap:4, padding:"4px 8px", borderBottom:"1px solid var(--border)", background:"var(--bg-panel)", flexShrink:0, ...(dense ? { overflowX:"auto" as const, flexWrap:"nowrap" as const, minHeight:33 } : {}) }}>
|
|
1303
|
+
{/* 上一个目录:回到允许浏览的父级(后端按 file roots 判定;父级不在允许集合内=已到边界,隐藏) */}
|
|
1304
|
+
{parent ? (
|
|
1305
|
+
<button type="button" onClick={()=>{ saveExpandedSnapshot(root, expanded); setParent(null); setRoot(parent); setExpanded(loadExpandedSnapshot(parent) ?? new Set()); }} title={t("files.parentDir")} aria-label={t("files.parentDir")}
|
|
1306
|
+
style={{ height:22, padding:"0 6px", border:"1px solid var(--border)", borderRadius:4, background:"var(--bg-panel)", color:"var(--text-muted)", cursor:"pointer", fontSize:12, flexShrink:0 }}>↑</button>
|
|
1307
|
+
) : null}
|
|
1308
|
+
<FolderSwitcher path={root} onSelect={(p)=>{ saveExpandedSnapshot(root, expanded); setParent(null); setRoot(p); setExpanded(loadExpandedSnapshot(p) ?? new Set()); }} />
|
|
1309
|
+
{/* + 菜单:新建文件 / 新建文件夹 / 上传文件 / 上传文件夹 */}
|
|
1310
|
+
<div ref={plusRef} style={{ position:"relative", flexShrink:0 }}>
|
|
1311
|
+
{TBtn({ key:"plus", onClick:()=>{ if (plusOpen) { setPlusOpen(false); } else { setPlusPos(null); setPlusOpen(true); } }, title:t("files.newFile")+" / "+t("files.newFolder")+" / "+t("files.uploadFile")+" / "+t("files.uploadFolder"), color:plusOpen?"var(--accent)":"var(--text-dim)", bg:plusOpen?"var(--bg-selected)":"transparent", children:<PlusIcon/> })}
|
|
1312
|
+
</div>
|
|
1313
|
+
<span style={{flex:1}} />
|
|
1314
|
+
{/* 开放点:其他扩展的目录级工具条按钮(如 aiti-git-review 的「审此目录」——仅该目录下有变更时 match 返回 true) */}
|
|
1315
|
+
{folderActions.filter((a) => { try { return a.match && a.match(root, { gitFiles }); } catch { return false; } }).map((a) =>
|
|
1316
|
+
TBtn({ key: String(a.id || a.title || (typeof a.label === "string" ? a.label : "act")), onClick: () => { try { a.onClick(root); } catch {/* noop */} }, title: String(a.title || (typeof a.label === "string" ? a.label : "")), children: a.icon ? a.icon : <span style={{ fontSize: 13, lineHeight: 1 }}>{a.label}</span> }))}
|
|
1317
|
+
<input ref={uploadInputRef} type="file" multiple hidden onChange={e=>void handleUploadInput(e)} />
|
|
1318
|
+
<input ref={dirUploadInputRef} type="file" multiple hidden webkitdirectory="" onChange={e=>void handleDirUploadInput(e)} />
|
|
1319
|
+
{TBtn({ key:"srch", onClick:toggleSearch, title:t("sidebar.searchFiles"), pressed:searchOpen, color:searchOpen?"var(--accent)":"var(--text-dim)", bg:searchOpen?"var(--bg-selected)":"transparent", children:<MiSearch/> })}
|
|
1320
|
+
{gitFiles.length>0 ? TBtn({ key:"ch", onClick:()=>setChangesOpen(v=>!v), title:t("sidebar.changedFiles",{count:gitFiles.length}), pressed:changesOpen, color:changesOpen?"var(--accent)":"var(--text-dim)", bg:changesOpen?"var(--bg-selected)":"transparent",
|
|
1321
|
+
children: <span style={{position:"relative",display:"inline-flex"}}>
|
|
1322
|
+
<svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden><circle cx="12" cy="12" r="3"/><path d="M3 12h6"/><path d="M15 12h6"/></svg>
|
|
1323
|
+
<span style={{position:"absolute",top:-6,right:-8,fontSize:9,color:"var(--text-muted)",background:"var(--bg-panel)",border:"1px solid var(--border)",borderRadius:6,padding:"0 3px",lineHeight:1.4}}>{String(gitFiles.length)}</span>
|
|
1324
|
+
</span> }) : null}
|
|
1325
|
+
{TBtn({ key:"term", onClick:()=>void openTerminal(root).then(err=>{ if (err) setUploadMsg({ err }); }), title:t("files.openTerminal"), children:<TerminalIcon/> })}
|
|
1326
|
+
{TBtn({ key:"nw", onClick:()=>openNewWindow("folder", root), title:t("files.openNewWindow"), children:<NewWindowIcon/> })}
|
|
1327
|
+
{TBtn({ key:"rf", onClick:doRefresh, title:t("sidebar.refreshExplorer"), color:refreshDone?"var(--ok, #4ade80)":"var(--text-dim)", bg:refreshDone?"color-mix(in srgb, var(--ok, #4ade80) 18%, transparent)":"transparent",
|
|
1328
|
+
children: refreshDone
|
|
1329
|
+
? <svg width={13} height={13} viewBox="0 0 24 24" fill="none" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" style={{ stroke: "var(--ok, #4ade80)" }}><polyline points="20 6 9 17 4 12"/></svg>
|
|
1330
|
+
: <svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg> })}
|
|
1331
|
+
{/* @ 将路径插入聊天:高频动作恒留外面(对齐文件行操作区),不收进 ⋯ 菜单 */}
|
|
1332
|
+
{onAtMention ? TBtn({ key:"at", onClick:()=>onAtMention(relTo(root,cwd),false), title:t("files.insertPath"), color:"var(--accent)", children:<span style={{fontSize:12,fontWeight:700,lineHeight:1}}>@</span> }) : null}
|
|
1333
|
+
{/* ⋯ 更多菜单:目录级共享菜单(刷新/复制路径/新窗口/重命名/@到新session/信息/上级目录/下载/Git Review)——与文件顶栏 ⋯ 菜单对齐 */}
|
|
1334
|
+
<div ref={moreRef} style={{ position:"relative", flexShrink:0 }}>
|
|
1335
|
+
{TBtn({ key:"more", onClick:toggleMore, title:t("files.rename")+" / "+t("files.copyPath")+" / "+t("files.entryInfo")+" / "+t("files.download"), color:moreOpen?"var(--accent)":"var(--text-dim)", bg:moreOpen?"var(--bg-selected)":"transparent", children:<span style={{fontSize:14,fontWeight:700,lineHeight:1}}>⋮</span> })}
|
|
1336
|
+
{moreOpen ? (
|
|
1337
|
+
<div style={{ position:"fixed", left:morePos!.left, top:morePos!.top, zIndex:400, minWidth:180, padding:6, background:"var(--bg,#fff)", border:"1px solid var(--border,#d0d0d0)", borderRadius:10, boxShadow:"0 4px 12px rgba(0,0,0,0.15)", fontSize:12, display:"flex", flexDirection:"column", gap:2 }}>
|
|
1338
|
+
{moreRename ? (
|
|
1339
|
+
<div style={{display:"flex",flexDirection:"column",gap:6,padding:"2px 2px 4px"}}>
|
|
1340
|
+
<div style={{fontSize:11,color:"var(--text-dim,#888)",fontWeight:600}}>{t("files.rename")}</div>
|
|
1341
|
+
<input autoFocus value={moreRenameName} onChange={e=>setMoreRenameName(e.target.value)} onKeyDown={e=>{ if(e.key==="Enter") void doRenameRoot(); if(e.key==="Escape") setMoreRename(false); }} placeholder={(t("files.renamePlaceholder") as string)||""}
|
|
1342
|
+
style={{width:"100%",boxSizing:"border-box",fontSize:12,fontFamily:"var(--font-mono,ui-monospace,monospace)",padding:"5px 8px",border:"1px solid var(--border,#d0d0d0)",borderRadius:5,outline:"none",background:"var(--bg,#fff)",color:"var(--text,#222)"}} />
|
|
1343
|
+
<div style={{display:"flex",gap:6,justifyContent:"flex-end"}}>
|
|
1344
|
+
<button type="button" onClick={()=>setMoreRename(false)} style={{...createBtnStyle,background:"transparent",border:"1px solid var(--border,#d0d0d0)",color:"var(--text,#222)"}}>{t("files.cancel")}</button>
|
|
1345
|
+
<button type="button" onClick={()=>void doRenameRoot()} disabled={!moreRenameName.trim()||moreBusy} style={{...createBtnStyle,background:"var(--accent,#4f8ef7)",color:"#fff",border:"none",opacity:!moreRenameName.trim()||moreBusy?0.5:1}}>{moreBusy?"…":t("files.confirm")}</button>
|
|
1346
|
+
</div>
|
|
1347
|
+
</div>
|
|
1348
|
+
) : [
|
|
1349
|
+
// ── 菜单分组重排(2026-09-16 用户拍板,对齐 aiti-editor ⋯ 菜单):
|
|
1350
|
+
// 钉住 → 关闭图标排 → 导航(上一个目录)→ 导出(下载)→ 路径引用(复制路径/@到新会话)
|
|
1351
|
+
// → 打开/查看 → 整理 → Git。关闭族由 5 条文字项收编为 4 连图标排(同编辑器)。──
|
|
1352
|
+
menuHead(t("files.menuTabs"), "h-tabs"),
|
|
1353
|
+
menuItem(isTabPinned() ? <CheckIcon/> : <MiPin/>, isTabPinned() ? t("files.unpin") : t("files.pin"), toggleTabPin, "pin"),
|
|
1354
|
+
<FolderCloseIconRow key="close-row" closeTab={()=>{ setMoreOpen(false); closeCurrentTab(); }} batch={(scope)=>{ setMoreOpen(false); batchCloseTabs(scope); }} t={t} />,
|
|
1355
|
+
menuSep("s-nav"), menuHead(t("files.menuNav"), "h-nav"),
|
|
1356
|
+
menuItem(<MiArrowUp/>, t("files.openParentDir"), doOpenParent, "op"),
|
|
1357
|
+
// ── 导出(≈保存组)──
|
|
1358
|
+
menuSep("s-exp"), menuHead(t("files.menuExport"), "h-exp"),
|
|
1359
|
+
menuItem(<MiDownload/>, t("files.download"), ()=>void doDownloadRoot(), "dl"),
|
|
1360
|
+
// ── 路径引用(@ 组)──
|
|
1361
|
+
menuSep("s-ref"), menuHead(t("files.menuRef"), "h-ref"),
|
|
1362
|
+
menuItem(<MiCopy/>, t("files.copyPath"), doCopyRoot, "cp"),
|
|
1363
|
+
menuItem(<MiAt/>, t("files.atNewSession"), doRootAtNew, "atn"),
|
|
1364
|
+
// ── 打开/查看 ──
|
|
1365
|
+
menuSep("s-open"), menuHead(t("files.menuOpen"), "h-open"),
|
|
1366
|
+
menuItem(<MiRefresh/>, t("files.refresh"), ()=>{ setMoreOpen(false); doRefresh(); }, "rf"),
|
|
1367
|
+
menuItem(<NewWindowIcon/>, t("files.openNewWindow"), ()=>{ setMoreOpen(false); openNewWindow("folder", root); }, "nw"),
|
|
1368
|
+
menuItem(<TerminalIcon/>, t("files.openTerminal"), ()=>{ setMoreOpen(false); void openTerminal(root).then(err=>{ if (err) setUploadMsg({ err }); }); }, "ot"),
|
|
1369
|
+
menuItem(<MiInfo/>, t("files.entryInfo"), ()=>{ setMoreOpen(false); setMoreInfoOpen(true); }, "info"),
|
|
1370
|
+
// ── 整理(重命名/移动到/复制到)──
|
|
1371
|
+
menuSep("s-org"), menuHead(t("files.menuOrganize"), "h-org"),
|
|
1372
|
+
menuItem(<MiPencil/>, t("files.rename"), ()=>{ setMoreRename(true); setMoreRenameName(lastSeg(root)||""); }, "rn"),
|
|
1373
|
+
menuItem(<MiMove/>, t("files.move"), ()=>{ setMoreOpen(false); setMoreTransfer("move"); }, "mv"),
|
|
1374
|
+
menuItem(<MiCopyTo/>, t("files.copyTo"), ()=>{ setMoreOpen(false); setMoreTransfer("copy"); }, "cpto"),
|
|
1375
|
+
// ── Git ──
|
|
1376
|
+
menuSep("s-git"), menuHead(t("files.menuGit"), "h-git"),
|
|
1377
|
+
<button type="button" key="gr" onClick={(e)=>{ e.stopPropagation(); try{ grOpen() && grOpen({ scope:{ kind:"directory", path: root } }); grWork() && grWork(); }catch(_){/* noop */} setMoreOpen(false); }} style={{ display:"flex", alignItems:"center", gap:6, width:"100%", padding:"5px 10px", border:"none", background:"transparent", color:"var(--accent)", cursor:"pointer", textAlign:"left", fontSize:12, borderRadius:6, whiteSpace:"nowrap" }}>
|
|
1378
|
+
<GrEyeIcon size={13} />
|
|
1379
|
+
Git Review
|
|
1380
|
+
</button>,
|
|
1381
|
+
]}
|
|
1382
|
+
</div>
|
|
1383
|
+
) : null}
|
|
1384
|
+
</div>
|
|
1385
|
+
{uploadMsg ? <span style={{fontSize:10,color:uploadMsg.err?"var(--danger, #f87171)":(uploadMsg.ok?"var(--ok, #4ade80)":"var(--text-muted)"),overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",marginLeft:2}} title={String(uploadMsg.err||"")}>{uploadMsg.err ? uploadMsg.err : (uploadMsg.ok ? uploadMsg.ok : ((uploadMsg.up||0)+"↑ / "+(uploadMsg.skip||0)+"跳 / "+(uploadMsg.err||0)+"错"))}</span> : null}
|
|
1386
|
+
</div>)}
|
|
1387
|
+
{dockHoist && uploadMsg ? (
|
|
1388
|
+
<div style={{ padding:"3px 8px", fontSize:10, borderBottom:"1px solid var(--border)", background:"var(--bg-panel)", color: uploadMsg.err ? "var(--danger, #f87171)" : (uploadMsg.ok ? "var(--ok, #4ade80)" : "var(--text-muted)"), overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", flexShrink:0 }} title={String(uploadMsg.err||"")}>
|
|
1389
|
+
{uploadMsg.err ? uploadMsg.err : (uploadMsg.ok ? uploadMsg.ok : ((uploadMsg.up||0)+"↑ / "+(uploadMsg.skip||0)+"跳 / "+(uploadMsg.err||0)+"错"))}
|
|
1390
|
+
</div>
|
|
1391
|
+
) : null}
|
|
1392
|
+
{plusOpen ? (
|
|
1393
|
+
<div ref={plusMenuRef} style={{ position:"fixed", zIndex:120, minWidth:180, padding:6, background:"var(--bg,#fff)", border:"1px solid var(--border,#d0d0d0)", borderRadius:10, boxShadow:"0 4px 12px rgba(0,0,0,0.15)", fontSize:12, display:"flex", flexDirection:"column", gap:2, ...(plusPos ? { left: plusPos.left, top: plusPos.top } : { left: -99999, top: -99999 }) }}>
|
|
1394
|
+
{newKind ? (
|
|
1395
|
+
<div style={{ display:"flex", flexDirection:"column", gap:6, padding:"2px 2px 4px" }}>
|
|
1396
|
+
<div style={{fontSize:11,color:"var(--text-dim,#888)",fontWeight:600}}>{newKind==="file"?t("files.newFile"):t("files.newFolder")}</div>
|
|
1397
|
+
<input autoFocus value={newName} onChange={e=>setNewName(e.target.value)} onKeyDown={e=>{ if(e.key==="Enter") void handleCreate(); if(e.key==="Escape") setNewKind(null); }} placeholder={newKind==="file"?t("files.namePlaceholderFile"):t("files.namePlaceholderFolder")}
|
|
1398
|
+
style={{ width:"100%", boxSizing:"border-box", fontSize:12, fontFamily:"var(--font-mono,ui-monospace,monospace)", padding:"5px 8px", border:"1px solid var(--border,#d0d0d0)", borderRadius:5, outline:"none", background:"var(--bg,#fff)", color:"var(--text,#222)" }} />
|
|
1399
|
+
<div style={{display:"flex",gap:6,justifyContent:"flex-end"}}>
|
|
1400
|
+
<button type="button" onClick={()=>setNewKind(null)} style={{...createBtnStyle,background:"transparent",border:"1px solid var(--border,#d0d0d0)",color:"var(--text,#222)"}}>{t("files.cancel")}</button>
|
|
1401
|
+
<button type="button" onClick={()=>void handleCreate()} disabled={!newName.trim()||createBusy} style={{...createBtnStyle,background:"var(--accent,#4f8ef7)",color:"#fff",border:"none",opacity:!newName.trim()||createBusy?0.5:1}}>{createBusy?"…":t("files.create")}</button>
|
|
1402
|
+
</div>
|
|
1403
|
+
</div>
|
|
1404
|
+
) : [
|
|
1405
|
+
menuItem(<MiFilePlus/>, t("files.newFile"), ()=>{ setNewKind("file"); setNewName(""); }, "nf"),
|
|
1406
|
+
menuItem(<MiFolderPlus/>, t("files.newFolder"), ()=>{ setNewKind("folder"); setNewName(""); }, "nd"),
|
|
1407
|
+
<div key="sep" style={{height:1,background:"var(--border,#eee)",margin:"2px 4px"}} />,
|
|
1408
|
+
menuItem(<MiUpload/>, t("files.uploadFile"), ()=>{ setPlusOpen(false); if(uploadInputRef.current) uploadInputRef.current.click(); }, "uf"),
|
|
1409
|
+
menuItem(<MiUpload/>, t("files.uploadFolder"), ()=>{ setPlusOpen(false); if(dirUploadInputRef.current) dirUploadInputRef.current.click(); }, "ud"),
|
|
1410
|
+
]}
|
|
1411
|
+
</div>
|
|
1412
|
+
) : null}
|
|
1413
|
+
{moreInfoOpen ? <EntryInfoModal path={root} onClose={()=>setMoreInfoOpen(false)} t={t} onOpenFolder={onOpenFolder} /> : null}
|
|
1414
|
+
{moreTransfer ? <TransferDialog path={root} cwd={cwd} root={root} mode={moreTransfer} t={t} onClose={()=>setMoreTransfer(false)} onMoved={(newPath)=>{ setMoreTransfer(false); if(moreTransfer==="copy"){ setUploadMsg({ ok: t("files.copyDoneAction")+" ✓" }); return; } if(newPath && newPath!==root){ saveExpandedSnapshot(root, expanded); setParent(null); setRoot(newPath); setExpanded(loadExpandedSnapshot(newPath) ?? new Set()); if(onOpenFolder) onOpenFolder(newPath, lastSeg(newPath) || undefined); } else { doRefresh(); } }} /> : null}
|
|
1415
|
+
{dockHoist && moreRename ? (
|
|
1416
|
+
<div onClick={(e)=>{ if(e.target===e.currentTarget) setMoreRename(false); }} style={{ position:"fixed", inset:0, zIndex:400, background:"rgba(0,0,0,0.35)", display:"flex", alignItems:"center", justifyContent:"center" }}>
|
|
1417
|
+
<div style={{ width:"min(420px,92vw)", background:"var(--bg-panel)", border:"1px solid var(--border)", borderRadius:8, boxShadow:"0 8px 32px rgba(0,0,0,0.3)", padding:12, display:"flex", flexDirection:"column", gap:8 }}>
|
|
1418
|
+
<div style={{ fontSize:12, fontWeight:600, color:"var(--text)" }}>{t("files.rename")}</div>
|
|
1419
|
+
<input autoFocus value={moreRenameName} onChange={e=>setMoreRenameName(e.target.value)}
|
|
1420
|
+
onKeyDown={e=>{ if(e.key==="Enter") void doRenameRoot(); if(e.key==="Escape") setMoreRename(false); }}
|
|
1421
|
+
placeholder={(t("files.renamePlaceholder") as string)||""}
|
|
1422
|
+
style={{ width:"100%", boxSizing:"border-box", fontSize:12, fontFamily:"var(--font-mono,ui-monospace,monospace)", padding:"5px 8px", border:"1px solid var(--border)", borderRadius:5, outline:"none", background:"var(--bg)", color:"var(--text)" }} />
|
|
1423
|
+
<div style={{ display:"flex", gap:6, justifyContent:"flex-end" }}>
|
|
1424
|
+
<button type="button" onClick={()=>setMoreRename(false)} style={{ ...createBtnStyle, background:"transparent", border:"1px solid var(--border)", color:"var(--text)" }}>{t("files.cancel")}</button>
|
|
1425
|
+
<button type="button" onClick={()=>void doRenameRoot()} disabled={!moreRenameName.trim()||moreBusy} style={{ ...createBtnStyle, background:"var(--accent)", color:"#fff", border:"none", opacity:!moreRenameName.trim()||moreBusy?0.5:1 }}>{moreBusy?"…":t("files.confirm")}</button>
|
|
1426
|
+
</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
) : null}
|
|
1430
|
+
<div style={{ flex:1, overflowY:"auto", overflowX:"hidden", padding:"2px 0" }}>
|
|
1431
|
+
{/* 搜索条 + 结果树(对齐 pi-web 原版:搜索打开时显示在树区顶部;有查询时替换普通树) */}
|
|
1432
|
+
{searchOpen ? (
|
|
1433
|
+
<div style={{ padding:"6px 8px", borderBottom:"1px solid var(--border)" }}>
|
|
1434
|
+
<div style={{ position:"relative" }}>
|
|
1435
|
+
<svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
|
1436
|
+
style={{ position:"absolute", left:8, top:"50%", transform:"translateY(-50%)", color:"var(--text-dim)", pointerEvents:"none" }}>
|
|
1437
|
+
<circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" />
|
|
1438
|
+
</svg>
|
|
1439
|
+
<input ref={searchInputRef} value={searchQuery} onChange={e=>setSearchQuery(e.target.value)}
|
|
1440
|
+
onKeyDown={e=>{ if(e.key==="Escape") toggleSearch(); }}
|
|
1441
|
+
placeholder={t("sidebar.searchFilesPlaceholder")} aria-label={t("sidebar.searchFiles")}
|
|
1442
|
+
style={{ width:"100%", boxSizing:"border-box", padding:"6px 24px", border:"1px solid var(--border)", borderRadius:5, outline:"none", background:"var(--bg)", color:"var(--text)", fontFamily:"var(--font-mono)", fontSize:11 }} />
|
|
1443
|
+
{searchQuery ? (
|
|
1444
|
+
<button type="button" onClick={()=>setSearchQuery("")} title={t("sidebar.clearSearch")} aria-label={t("sidebar.clearSearch")}
|
|
1445
|
+
style={{ position:"absolute", right:4, top:"50%", transform:"translateY(-50%)", display:"flex", alignItems:"center", justifyContent:"center", width:18, height:18, padding:0, border:"none", borderRadius:4, background:"none", color:"var(--text-dim)", cursor:"pointer" }}>
|
|
1446
|
+
<svg width={10} height={10} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
1447
|
+
</button>
|
|
1448
|
+
) : null}
|
|
1449
|
+
</div>
|
|
1450
|
+
{/* 模式页签 + 全 workspace 开关(doc-center 不可用时页签行不渲染) */}
|
|
1451
|
+
{docsAvailable !== false ? (
|
|
1452
|
+
<div style={{ display:"flex", alignItems:"center", gap:2, padding:"4px 2px 0" }}>
|
|
1453
|
+
{(["files", "fulltext"] as const).map((m) => {
|
|
1454
|
+
const label = m === "files" ? t("sidebar.searchTabFiles") : t("sidebar.searchTabFulltext");
|
|
1455
|
+
const on = searchMode === m;
|
|
1456
|
+
return <button key={m} type="button" onClick={()=>switchSearchMode(m)}
|
|
1457
|
+
style={{ height:18, padding:"0 8px", fontSize:10, border:"none", borderRadius:4, cursor:"pointer", fontWeight:on?600:400,
|
|
1458
|
+
background:on?"var(--bg-selected)":"transparent", color:on?"var(--accent)":"var(--text-dim)" }}>{label}</button>;
|
|
1459
|
+
})}
|
|
1460
|
+
{searchMode==="fulltext" ? (
|
|
1461
|
+
<span title={t("sidebar.fulltextHintTitle")}
|
|
1462
|
+
style={{ fontSize:9, color:"var(--text-dim)", opacity:0.85, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", minWidth:0, flexShrink:1, marginLeft:2 }}>{t("sidebar.fulltextHint")}</span>
|
|
1463
|
+
) : null}
|
|
1464
|
+
<span style={{ flex:1 }} />
|
|
1465
|
+
{searchMode==="fulltext" ? (
|
|
1466
|
+
<label title={t("sidebar.fullScopeAll")} style={{ display:"flex", alignItems:"center", gap:3, fontSize:10, color:"var(--text-dim)", cursor:"pointer" }}>
|
|
1467
|
+
<input type="checkbox" checked={fullScope} onChange={e=>setFullScope(e.target.checked)} style={{ width:10, height:10, margin:0, accentColor:"var(--accent)" }} />
|
|
1468
|
+
{t("sidebar.fullScopeAll")}
|
|
1469
|
+
</label>
|
|
1470
|
+
) : null}
|
|
1471
|
+
</div>
|
|
1472
|
+
) : null}
|
|
1473
|
+
{hasSearchQuery ? (
|
|
1474
|
+
<div style={{ paddingTop:3 }}>
|
|
1475
|
+
{searchLoading ? <div role="status" style={{ padding:"6px 2px", fontSize:10, color:"var(--text-dim)" }}>{searchMode==="fulltext" ? t("sidebar.searchingDocs") : t("sidebar.searchingFiles")}</div> : null}
|
|
1476
|
+
{!searchLoading && searchError ? <div role="alert" style={{ padding:"6px 2px", fontSize:10, color:"var(--danger, #f87171)" }}>{t("i18n.networkError")}</div> : null}
|
|
1477
|
+
{!searchLoading && !searchError && (searchMode==="files" ? searchPaths.length===0 : visibleDocs.length===0) ? <div style={{ padding:"6px 2px", fontSize:10, color:"var(--text-dim)" }}>{searchMode==="fulltext" ? t("sidebar.noMatchingDocs") : t("sidebar.noMatchingFiles")}</div> : null}
|
|
1478
|
+
{!searchLoading && !searchError && searchMode==="files" && searchPaths.length>0 ? (
|
|
1479
|
+
<div>
|
|
1480
|
+
{searchRoots.map((node:any)=> (
|
|
1481
|
+
<TreeNode key={searchQuery+":"+node.fullPath} node={node} depth={0} cwd={cwd||root} expanded={searchExpanded}
|
|
1482
|
+
onToggle={(fp:string,o:boolean)=>setSearchExpanded(prev=>{ const n=new Set(prev); if(o) n.add(fp); else n.delete(fp); return n; })}
|
|
1483
|
+
onOpenFile={onOpenFile} onOpenFolder={onOpenFolder} onAtMention={onAtMention} onTrash={setPendingTrash} onChanged={doRefresh}
|
|
1484
|
+
gitByPath={gitByPath} changedDirs={changedDirs} t={t} root={root} />
|
|
1485
|
+
))}
|
|
1486
|
+
</div>
|
|
1487
|
+
) : null}
|
|
1488
|
+
{!searchLoading && !searchError && searchMode==="fulltext" && visibleDocs.length>0 ? (
|
|
1489
|
+
<div style={{ display:"flex", flexDirection:"column", gap:1 }}>
|
|
1490
|
+
{visibleDocs.map((d:any) => (
|
|
1491
|
+
<div key={d.path} onClick={()=>onOpenFile(d.path)} title={d.path}
|
|
1492
|
+
style={{ display:"flex", flexDirection:"column", gap:1, padding:"3px 10px", cursor:"pointer", borderRadius:4, userSelect:"none" }}
|
|
1493
|
+
onMouseEnter={e=>(e.currentTarget.style.background="var(--bg-hover)")} onMouseLeave={e=>(e.currentTarget.style.background="transparent")}>
|
|
1494
|
+
<div style={{ display:"flex", alignItems:"baseline", gap:6 }}>
|
|
1495
|
+
<span style={{ fontSize:12, color:"var(--text)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", flex:1 }}>{d.title || lastSeg(d.path)}</span>
|
|
1496
|
+
{d.mtimeMs ? <span style={{ fontSize:9, color:"var(--text-dim)", flexShrink:0 }}>{new Date(d.mtimeMs).toLocaleDateString()}</span> : null}
|
|
1497
|
+
</div>
|
|
1498
|
+
<span style={{ fontSize:10, color:"var(--text-dim)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{relTo(d.path, root) || d.path}</span>
|
|
1499
|
+
{d.snippet ? <span style={{ fontSize:11, color:"var(--text-muted)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}><MarkedText text={d.snippet} /></span> : null}
|
|
1500
|
+
</div>
|
|
1501
|
+
))}
|
|
1502
|
+
</div>
|
|
1503
|
+
) : null}
|
|
1504
|
+
</div>
|
|
1505
|
+
) : null}
|
|
1506
|
+
</div>
|
|
1507
|
+
) : null}
|
|
1508
|
+
{/* git 工具条(引擎在位才渲染;⎇ 分支面板 / ↑↓ 同步 / ✎ 提交——git-ops M3) */}
|
|
1509
|
+
<GitBar root={root} git={folderGit} onChanged={doRefresh} changesOpen={changesOpen} onToggleChanges={() => setChangesOpen((v: boolean) => !v)} />
|
|
1510
|
+
{/* 变更区段(展开且有意变更) */}
|
|
1511
|
+
{(changesOpen && gitFiles.length>0) ? (
|
|
1512
|
+
<div style={{ padding:"0 4px 2px", borderBottom:"1px solid var(--border)" }}>
|
|
1513
|
+
<div style={{ display:"flex", alignItems:"center", gap:6, height:24, padding:"0 10px", fontSize:12 }}>
|
|
1514
|
+
<span style={{color:"var(--text-dim)"}}>{t("files.changedCount",{count:gitFiles.length})}</span>
|
|
1515
|
+
<span style={{color:"var(--ok, #4ade80)",fontFamily:"var(--font-mono)"}}>+{gitStats.additions}</span>
|
|
1516
|
+
<span style={{color:"var(--danger, #f87171)",fontFamily:"var(--font-mono)"}}>-{gitStats.deletions}</span>
|
|
1517
|
+
</div>
|
|
1518
|
+
{(() => {
|
|
1519
|
+
// 双列态(git-ops §4):xy[0](index)非空且非 ? → 已暂存桶,其余 → 工作区桶;
|
|
1520
|
+
// xy 来源 = 引擎 refs.dirtyFiles(-z 原始路径);引擎缺席(avail!==true)不分行、不显行钮。
|
|
1521
|
+
const xyMap = new Map((folderGit.refs?.dirtyFiles ?? []).map((f) => [f.path.replace(/\\/g, "/"), f.xy]));
|
|
1522
|
+
const relOf = (filePath: string) => (relFromBase(filePath, root) || filePath).replace(/\\/g, "/");
|
|
1523
|
+
const relRows = gitFiles.map((st: any) => ({ st, rel: relOf(st.filePath) }));
|
|
1524
|
+
const isStaged = (rel: string) => { const xy = xyMap.get(rel) ?? xyMap.get(rel + "/"); return !!xy && xy[0] !== " " && xy[0] !== "?"; };
|
|
1525
|
+
const stagedRows = folderGit.avail === true ? relRows.filter((r) => isStaged(r.rel)) : [];
|
|
1526
|
+
const unstagedRows = relRows.filter((r) => !stagedRows.includes(r));
|
|
1527
|
+
const subHead = (label: string, count: number, key: string) => (
|
|
1528
|
+
<div key={key} style={{ padding:"4px 10px 1px", fontSize:10, fontWeight:600, letterSpacing:"0.05em", textTransform:"uppercase" as const, color:"var(--text-dim)" }}>{label} ({count})</div>
|
|
1529
|
+
);
|
|
1530
|
+
const rowEl = (r: { st: any; rel: string }) => (
|
|
1531
|
+
<ChangedFileRow key={r.st.filePath} st={r.st} root={root} cwd={cwd||root} onOpenFile={onOpenFile} onOpenFolder={onOpenFolder} onAtMention={onAtMention} onTrash={setPendingTrash} t={t} onRefresh={doRefresh}
|
|
1532
|
+
staged={folderGit.avail === true ? isStaged(r.rel) : undefined}
|
|
1533
|
+
gitOp={folderGit.avail === true ? (kind, rel) => { void folderGit.op(kind, rel); } : undefined} />
|
|
1534
|
+
);
|
|
1535
|
+
return (
|
|
1536
|
+
<>
|
|
1537
|
+
{folderGit.avail === true && stagedRows.length > 0 ? subHead(ct("git.stagedBucket"), stagedRows.length, "staged-head") : null}
|
|
1538
|
+
{(folderGit.avail === true ? stagedRows : []).map(rowEl)}
|
|
1539
|
+
{folderGit.avail === true ? subHead(ct("git.unstagedBucket"), unstagedRows.length, "unstaged-head") : null}
|
|
1540
|
+
{unstagedRows.map(rowEl)}
|
|
1541
|
+
</>
|
|
1542
|
+
);
|
|
1543
|
+
})()}
|
|
1544
|
+
</div>
|
|
1545
|
+
) : null}
|
|
1546
|
+
{/* 树(有搜索查询时隐藏,腾位给结果树;对齐 pi-web (!fileSearchOpen || !hasSearchQuery)) */}
|
|
1547
|
+
{!hasSearchQuery ? (loading ? <div style={{padding:"12px",fontSize:12,color:"var(--text-dim)"}}>{t("files.loading")}</div>
|
|
1548
|
+
: error ? <div style={{padding:"12px",fontSize:12,color:"var(--danger, #f87171)"}}>{error}</div>
|
|
1549
|
+
: roots.length===0 ? <div style={{padding:"12px",fontSize:12,color:"var(--text-dim)"}}>{t("files.noFiles")}</div>
|
|
1550
|
+
: roots.map((node:any)=> <TreeNode key={node.fullPath} node={node} depth={0} cwd={cwd||root} expanded={expanded} refreshKey={refreshKey} onToggle={toggle} onOpenFile={onOpenFile} onOpenFolder={onOpenFolder} onAtMention={onAtMention} onTrash={setPendingTrash} onChanged={doRefresh} gitByPath={gitByPath} changedDirs={changedDirs} highlightPath={highlightPath} t={t} root={root} showIgnored={showIgnored} />)) : null}
|
|
1551
|
+
</div>
|
|
1552
|
+
{/* 删除确认弹窗(移入系统回收站,可恢复) */}
|
|
1553
|
+
{pendingTrash ? (
|
|
1554
|
+
<div onClick={(e)=>{ if(e.target===e.currentTarget && !trashBusy) setPendingTrash(null); }} style={{ position:"fixed", inset:0, zIndex:400, background:"rgba(0,0,0,0.35)", display:"flex", alignItems:"center", justifyContent:"center" }}>
|
|
1555
|
+
<div style={{ width:360, padding:"16px 18px", borderRadius:12, background:"var(--bg,#fff)", border:"1px solid var(--border,#ddd)", boxShadow:"0 12px 40px rgba(0,0,0,0.25)", fontSize:13, display:"flex", flexDirection:"column", gap:10 }}>
|
|
1556
|
+
<div style={{ fontWeight:600, fontSize:14 }}>{t("files.trashConfirmTitle")}</div>
|
|
1557
|
+
<div style={{ fontFamily:"var(--font-mono,ui-monospace,monospace)", color:"var(--text-dim,#888)", fontSize:12, wordBreak:"break-all", lineHeight:1.5 }}>{pendingTrash.name}</div>
|
|
1558
|
+
<div style={{ color:"var(--text-dim,#888)", fontSize:12 }}>{t("files.trashConfirmBody")}</div>
|
|
1559
|
+
<div style={{ display:"flex", gap:8, justifyContent:"flex-end" }}>
|
|
1560
|
+
<button type="button" onClick={()=>setPendingTrash(null)} disabled={trashBusy} style={{ height:28, padding:"0 12px", borderRadius:5, background:"transparent", border:"1px solid var(--border,#d0d0d0)", color:"var(--text,#222)", cursor:trashBusy?"default":"pointer", fontSize:12 }}>{t("files.cancel")}</button>
|
|
1561
|
+
<button type="button" onClick={()=>void handleTrash()} disabled={trashBusy} style={{ height:28, padding:"0 12px", borderRadius:5, background:"var(--danger, #ef4444)", border:"none", color:"#fff", cursor:trashBusy?"default":"pointer", fontSize:12, fontWeight:600, opacity:trashBusy?0.6:1 }}>{trashBusy?"…":t("files.trash")}</button>
|
|
1562
|
+
</div>
|
|
1563
|
+
</div>
|
|
1564
|
+
</div>
|
|
1565
|
+
) : null}
|
|
1566
|
+
</div>
|
|
1567
|
+
);
|
|
1568
|
+
}
|