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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * aiti-folder · 线条图标与小组件(自手写版迁移,一比一)
3
+ */
4
+ import { lastSeg, EXT_LANG, LANG_COLOR, GIT_COLORS, GIT_KEYS } from "./lib";
5
+
6
+ type TFn = (key: string, params?: Record<string, unknown>) => string;
7
+
8
+ export function FileIcon() {
9
+ return (
10
+ <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}}>
11
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
12
+ <polyline points="14 2 14 8 20 8" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ export function FileLangDot({ name }: { name: string }) {
18
+ const ext = (lastSeg(name)!.split(".").pop() || "").toLowerCase();
19
+ const lang = EXT_LANG[ext]; if (!lang) return null;
20
+ const c = LANG_COLOR[lang]; if (!c) return null;
21
+ return <span style={{ width:6, height:6, borderRadius:2, background:c, flexShrink:0, display:"inline-block" }} />;
22
+ }
23
+
24
+ export function FolderIcon({ open, size }: { open?: boolean; size?: number }) {
25
+ return (
26
+ <svg width={size ?? 14} height={size ?? 14} viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}}>
27
+ <path d={ open ? "m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" : "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" } />
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ export function CaretIcon() {
33
+ return <svg width={9} height={9} viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}}><polyline points="2 3.5 5 6.5 8 3.5" /></svg>;
34
+ }
35
+
36
+ export function CheckIcon() {
37
+ return <svg width={10} height={10} viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}}><polyline points="1.5 5 4 7.5 8.5 2.5" /></svg>;
38
+ }
39
+
40
+ /* ── 工具条图标(v3:folder-dock 头行按钮与 FolderBrowser 工具栏共用) ── */
41
+ export function PlusIcon() {
42
+ return <svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
43
+ }
44
+
45
+ export function TerminalIcon() {
46
+ return <svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>;
47
+ }
48
+
49
+ /** 变更面板图标(git changes:圆点 + 出入线)。 */
50
+ export function ChangesIcon() {
51
+ return <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>;
52
+ }
53
+
54
+ export function PathLabel({ text, style }: { text?: string; style?: any }) {
55
+ return (
56
+ <span style={{ overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", display:"block", minWidth:0, lineHeight:1.35, direction:"rtl", textAlign:"left", ...(style||{}) }}>
57
+ <span style={{ unicodeBidi:"plaintext" }}>{text}</span>
58
+ </span>
59
+ );
60
+ }
61
+
62
+ export function ActivityBadges({ act, t }: { act?: { running: number; unread: number }; t: TFn }) {
63
+ if (!act || (act.running===0 && act.unread===0)) return null;
64
+ return (
65
+ <span style={{ display:"inline-flex", alignItems:"center", gap:5, flexShrink:0, marginLeft:6 }}>
66
+ {act.running>0 ? (
67
+ <span title={t("sidebar.agentRunning")} style={{ display:"inline-flex", alignItems:"center", gap:3, color:"var(--accent)", fontSize:10, fontFamily:"var(--font-mono)" }}>
68
+ <svg width={10} height={10} viewBox="0 0 24 24" fill="none" aria-hidden style={{display:"block"}}>
69
+ <g><path d="M21 12a9 9 0 1 1-3.8-7.4" stroke="currentColor" strokeWidth={2.8} strokeLinecap="round" />
70
+ <animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite" /></g>
71
+ </svg>
72
+ {String(act.running)}
73
+ </span>
74
+ ) : null}
75
+ {act.unread>0 ? (
76
+ <span title={t("sidebar.newSessionActivity")} style={{ display:"inline-flex", alignItems:"center", gap:3, color:"#0891b2", fontSize:10, fontFamily:"var(--font-mono)" }}>
77
+ <span style={{ width:6, height:6, borderRadius:"50%", background:"currentColor", display:"inline-block" }} />{String(act.unread)}
78
+ </span>
79
+ ) : null}
80
+ </span>
81
+ );
82
+ }
83
+
84
+ export function GitBadge({ status, t }: { status: { status: string; code: string }; t: TFn }) {
85
+ return (
86
+ <span title={t(GIT_KEYS[status.status]||status.status)} aria-label={t(GIT_KEYS[status.status]||status.status)}
87
+ style={{ width:14, height:14, flexShrink:0, display:"flex", alignItems:"center", justifyContent:"center", color:GIT_COLORS[status.status]||"var(--text-dim)", fontFamily:"var(--font-mono)", fontSize:11, fontWeight:600 }}>
88
+ {status.code}
89
+ </span>
90
+ );
91
+ }
92
+
93
+ // ── 菜单线稿图标(feather 风格 13px · stroke currentColor)——菜单项统一图标体系(弃 emoji) ──
94
+ function mkMenuIcon(render: () => any) {
95
+ return function MenuIcon({ size = 13 }: { size?: number }) {
96
+ // render() 必须在渲染时调用:模块顶层创建 JSX 时宿主 ctx.h 尚未注入,元素会全变 undefined
97
+ return (
98
+ <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>{render()}</svg>
99
+ );
100
+ };
101
+ }
102
+ // 图标内部一律用 Fragment(<>…</>)而非裸数组:数组子元素无 key 会触发 React
103
+ // "Each child in a list should have a unique key" dev 告警;Fragment 编译为
104
+ // jsxs(Fragment,…) → host-shim 的 jsxs 会对静态子元素打 validated 标记,告警消除。
105
+ // (2026-09-06 实证:host ctx.h = React.createElement,Fragment 真渲染;aiti-editor
106
+ // 同款改法浏览器已验。旧注“不渲染 Fragment/svg 会空”为当时误判,已废弃。)
107
+ export const MiRefresh = mkMenuIcon(() => <><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" /></>);
108
+ export const MiExternal = mkMenuIcon(() => <><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" /></>);
109
+ export const MiPencil = mkMenuIcon(() => <><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /></>);
110
+ export const MiMove = mkMenuIcon(() => <><polyline points="5 9 2 12 5 15" /><polyline points="9 5 12 2 15 5" /><polyline points="15 19 12 22 9 19" /><polyline points="19 9 22 12 19 15" /><line x1="2" y1="12" x2="22" y2="12" /><line x1="12" y1="2" x2="12" y2="22" /></>);
111
+ export const MiAt = mkMenuIcon(() => <><circle cx="12" cy="12" r="4" /><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" /></>);
112
+ export const MiInfo = mkMenuIcon(() => <><circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" /></>);
113
+ export const MiDownload = mkMenuIcon(() => <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>);
114
+ export const MiUpload = mkMenuIcon(() => <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></>);
115
+ export const MiTrash = mkMenuIcon(() => <><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" /></>);
116
+ export const MiTerminal = mkMenuIcon(() => <><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></>);
117
+ export const MiFilePlus = mkMenuIcon(() => <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="12" y1="18" x2="12" y2="12" /><line x1="9" y1="15" x2="15" y2="15" /></>);
118
+ export const MiFolderPlus = mkMenuIcon(() => <><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></>);
119
+ export const MiCopy = mkMenuIcon(() => <><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>);
120
+ // 复制到(copy + 右向箭头,与纯复制路径区分)
121
+ export const MiCopyTo = mkMenuIcon(() => <><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /><line x1="12" y1="15.5" x2="18.5" y2="15.5" /><polyline points="16 13 18.5 15.5 16 18" /></>);
122
+ export const MiArrowUp = mkMenuIcon(() => <><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5 12 12 5 19 12" /></>);
123
+ export const MiFolder = mkMenuIcon(() => <><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /></>);
124
+ export const MiChevronL = mkMenuIcon(() => <><polyline points="15 18 9 12 15 6" /></>);
125
+ export const MiChevronR = mkMenuIcon(() => <><polyline points="9 18 15 12 9 6" /></>);
126
+ export const MiFileText = mkMenuIcon(() => <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /></>);
127
+ export const MiSearch = mkMenuIcon(() => <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>);
128
+ // 显示忽略项(lucide eye 线稿)
129
+ export const MiEye = mkMenuIcon(() => <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" /></>);
130
+ // ── tab 窗口管理(钉住/关闭族)——与 aiti-editor file-toolbar 同款线稿,菜单视觉一致 ──
131
+ export const MiPin = mkMenuIcon(() => <><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></>);
132
+ export const MiX = mkMenuIcon(() => <><path d="M18 6 6 18"/><path d="m6 6 12 12"/></>);
133
+ export const MiCloseLeft = mkMenuIcon(() => <><line x1="3" y1="4" x2="3" y2="20"/><path d="m12 7-5 5 5 5"/><path d="M17 12H7"/></>);
134
+ export const MiCloseRight = mkMenuIcon(() => <><line x1="21" y1="4" x2="21" y2="20"/><path d="m12 7 5 5-5 5"/><path d="M7 12h10"/></>);
135
+ export const MiCloseAll = mkMenuIcon(() => <><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></>);
@@ -0,0 +1,2 @@
1
+ // esbuild 自动 JSX runtime 别名目标:jsx/jsxs/Fragment 全委托 host-shim
2
+ export { jsx, jsxs, Fragment } from "./host-shim";
@@ -0,0 +1,148 @@
1
+ /**
2
+ * aiti-folder · 共享工具与纯函数(自手写版迁移,逻辑一比一)
3
+ */
4
+
5
+ // ── 路径工具 ──
6
+ export function lastSeg(p?: string) { if (!p) return p; const s = p.replace(/[\\/]+$/, ""); const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\")); return i >= 0 ? s.slice(i + 1) : s; }
7
+ export function joinPath(dir?: string, name?: string) { if (!dir) return name; return dir.replace(/[\\/]+$/, "") + "/" + name; }
8
+ export function displayCwd(cwd?: string, home?: string) { return home && cwd && cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd; }
9
+ export function normSlash(p?: string) { return p ? p.replace(/\\/g, "/") : p; }
10
+ // 目录路径(等价 core getFileDirectory):"/a/b/c" → "/a/b","/a/b" → "/a","/a" → "/"
11
+ export function dirOf(p?: string) { const s = normSlash(p || "").replace(/\/+$/, ""); if (!s) return ""; const i = s.lastIndexOf("/"); if (i < 0) return ""; if (i === 0) return "/"; return s.slice(0, i); }
12
+ export function fileDir(p: string) { const s = normSlash(p); const i = s.lastIndexOf("/"); return i >= 0 ? s.slice(0, i) : s; }
13
+
14
+ // ── 简易文件图标数据(按扩展名着色,不引核心 FileIcons)──
15
+ export const EXT_LANG: Record<string, string> = { ts:"typescript",tsx:"typescript",js:"javascript",jsx:"javascript",mjs:"javascript",json:"json",css:"css",scss:"scss",html:"html",md:"markdown",yml:"yaml",yaml:"yaml",xml:"xml",svg:"xml",py:"python",go:"go",rs:"rust",java:"java",c:"c",cpp:"cpp",sh:"shell",sql:"sql",toml:"toml" };
16
+ export const LANG_COLOR: Record<string, string> = { typescript:"#3178c6", javascript:"#f1dd2f", json:"#a0a0a0", css:"#42a5f5", scss:"#cd6799", html:"#e44d26", markdown:"#999", yaml:"#cb171e", xml:"#e37935", python:"#3572a5", go:"#00add8", rust:"#dea584", java:"#b07219", c:"#555", cpp:"#f34b7d", shell:"#89e051", sql:"#e38c00", toml:"#9c4221" };
17
+
18
+ // ── 未读/项目聚合 ──
19
+ const UNREAD_KEY = "pi-web:unread-session-ids";
20
+ export function loadUnread(): Set<string> { try { const r = localStorage.getItem(UNREAD_KEY); const a = r?JSON.parse(r):[]; return new Set(Array.isArray(a)?a:[]); } catch { return new Set(); } }
21
+ // getRecentProjects 等价(客户端):按 projectKey/workspaceKey 去重取最新
22
+ function wkKey(s: any) { return s.projectKey || s.cwd || s.id; }
23
+ export function recentProjects(sessions: any[]) {
24
+ const m = new Map<string, { root: string; modified: string }>();
25
+ for (const s of sessions) { const root = s.projectRoot || s.cwd; if (!root) continue; const k = wkKey(s); const p = m.get(k); if (!p || s.modified > p.modified) m.set(k, { root, modified: s.modified }); }
26
+ return [...m.values()].sort((a,b)=> (b.modified||"").localeCompare(a.modified||"")).map(x=>({ key: x.root, root: x.root }));
27
+ }
28
+ export function projectActivity(sessions: any[], running: Set<string>, unread: Set<string>) {
29
+ const m = new Map<string, { running: number; unread: number }>();
30
+ for (const s of sessions) { const k = wkKey(s); if (!k) continue; let e = m.get(k); if (!e){ e={running:0,unread:0}; m.set(k,e);} if (running.has(s.id)) e.running++; if (unread.has(s.id)) e.unread++; }
31
+ return m;
32
+ }
33
+
34
+ // ── git 状态 ──
35
+ export const GIT_COLORS: Record<string, string> = { modified:"#d6a84b", added:"var(--ok, #4ade80)", deleted:"var(--danger, #f87171)", renamed:"#60a5fa", untracked:"var(--ok, #4ade80)", conflict:"var(--danger, #f87171)" };
36
+ export const GIT_KEYS: Record<string, string> = { modified:"files.modified", added:"files.added", deleted:"files.deleted", renamed:"files.renamed", untracked:"files.untracked", conflict:"files.conflict" };
37
+ export function buildGitByPath(files: any[]) { const m=new Map<string, any>(); for (const f of files) m.set(normSlash(f.filePath), f); return m; }
38
+ export function buildChangedDirs(files: any[], cwd?: string) {
39
+ const dirs=new Set<string>(); const nc = normSlash(cwd||"").replace(/\/$/,"");
40
+ for (const f of files) { let d=fileDir(normSlash(f.filePath));
41
+ while (d===nc || d.startsWith(nc+"/")) { dirs.add(d); if (d===nc) break; const p=fileDir(d); if (p===d) break; d=p; } }
42
+ return dirs;
43
+ }
44
+
45
+ export interface FolderList { entries: any[]; parentPath: string | null; }
46
+ export async function fetchListRaw(apiUrlFn: (p: string) => string, dirPath: string, showIgnored?: boolean): Promise<FolderList> {
47
+ const res = await fetch(apiUrlFn(showIgnored ? "/api/files/get?hidden=1" : "/api/files/get"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: dirPath, type: "list" }) });
48
+ if (!res.ok) throw new Error("HTTP "+res.status);
49
+ const d = await res.json();
50
+ return {
51
+ entries: (d.entries||[]).map((e: any)=>({ name:e.name, fullPath:joinPath(dirPath, e.name), isDir:e.isDir, size:e.size, ignored:!!e.ignored || e.hidden==="ignored", children:e.isDir?[]:undefined, loaded:!e.isDir })),
52
+ // 可上溯的父目录:后端返回 parentPath 时以其为边界(null = 已到可浏览边界);
53
+ // 旧后端未返回该字段则回退 dirOf(保持旧「尽量向上」行为)。「上一个目录」按钮据此显隐。
54
+ parentPath: ("parentPath" in d) ? (d.parentPath == null ? null : d.parentPath) as string | null : dirOf(dirPath),
55
+ };
56
+ }
57
+
58
+ // ── 目录列表存活缓存:切走再切回不重拉(TTL 内);手动刷新可强制绕过 ──
59
+ export const ENTRIES_TTL = 5 * 60 * 1000;
60
+ const entriesCache = new Map<string, { list: FolderList; ts: number }>();
61
+ /** 缓存 key:showIgnored 变体单独存(开关切换互不串),读写必须同源——peek 漏了变体位
62
+ * 会让「开启显示忽略项」的面板首挂命中普通列表缓存、渲染出缺忽略项的树。 */
63
+ function entriesKey(dirPath: string, showIgnored?: boolean): string {
64
+ return normSlash(dirPath)! + (showIgnored ? "#si1" : "");
65
+ }
66
+ function pruneEntries() { const now = Date.now(); for (const [k, v] of entriesCache) if (now - v.ts > ENTRIES_TTL) entriesCache.delete(k); }
67
+ export function invalidateEntries(dirPath?: string) { if (dirPath) { entriesCache.delete(entriesKey(dirPath)); entriesCache.delete(entriesKey(dirPath, true)); } else entriesCache.clear(); }
68
+ /** 同步读缓存(不发起请求);无新鲜缓存返回 null */
69
+ export function peekList(dirPath: string, showIgnored?: boolean): FolderList | null {
70
+ pruneEntries();
71
+ return entriesCache.get(entriesKey(dirPath, showIgnored))?.list ?? null;
72
+ }
73
+ /** 拉取目录列表(含 parentPath 边界);force 绕过存活缓存。showIgnored=true 时请求带忽略项
74
+ * (服务端返回置灰条目)。
75
+ * 参数/字段适配宿主上游语义(2026-11-04 修复):开关经 `?hidden=1`(上游 #1092 的参数名,
76
+ * 9-21 本地版曾用 showIgnored=1,上游合并后被覆盖——发旧参数服务端不识别,忽略项整体消失);
77
+ * 置灰标记读 `hidden==="ignored"`(宿主把本地 .gitignore/.aitignore 引擎命中统一归入该原因),
78
+ * 兼容旧 `ignored:true` 字段。 */
79
+ export async function fetchList(apiUrlFn: (p: string) => string, dirPath: string, opts?: { force?: boolean; showIgnored?: boolean }): Promise<FolderList> {
80
+ const key = entriesKey(dirPath, opts?.showIgnored);
81
+ pruneEntries();
82
+ if (!opts?.force) { const hit = entriesCache.get(key); if (hit) return hit.list; }
83
+ const list = await fetchListRaw(apiUrlFn, dirPath, opts?.showIgnored);
84
+ entriesCache.set(key, { list, ts: Date.now() });
85
+ return list;
86
+ }
87
+ /** 兼容旧签名(只返回 entries) */
88
+ export function peekEntries(dirPath: string, showIgnored?: boolean): any[] | null { return peekList(dirPath, showIgnored)?.entries ?? null; }
89
+ export async function fetchEntries(apiUrlFn: (p: string) => string, dirPath: string, opts?: { force?: boolean; showIgnored?: boolean }): Promise<any[]> {
90
+ return (await fetchList(apiUrlFn, dirPath, opts)).entries;
91
+ }
92
+
93
+ // 相对路径(@mention 用):base 前缀剥离
94
+ export function relTo(rp: string, base?: string) {
95
+ if (base && rp.startsWith(base)) { const s = rp.slice(base.length); return s.replace(/^[/\\]+/, ""); }
96
+ return rp;
97
+ }
98
+
99
+ // Windows 客户端判定(模块加载时一次):仅用于前缀比较是否大小写不敏感
100
+ const IS_WIN = typeof navigator !== "undefined" && (/win/i.test(navigator.platform || "") || /windows/i.test(navigator.userAgent || ""));
101
+
102
+ // 显示用相对路径(base 前缀剥离):先统一分隔符再比较,Windows 下大小写不敏感。
103
+ // 背景:/api/git/status 的 filePath 是服务端 path.resolve 产物(Windows 反斜杠、盘符大写),
104
+ // 而 root 可能是正斜杠/小写盘符形式——直接 startsWith(root+"/") 必失败,变更区整行
105
+ // 回退显示完整绝对路径(2026-09 修)。
106
+ export function relFromBase(p: string, base?: string): string {
107
+ const np = normSlash(p) || "";
108
+ const nb = normSlash(base || "");
109
+ if (!nb) return np;
110
+ const prefix = nb.endsWith("/") ? nb : nb + "/";
111
+ const hit = IS_WIN ? np.toLowerCase().startsWith(prefix.toLowerCase()) : np.startsWith(prefix);
112
+ return hit ? np.slice(prefix.length) : np;
113
+ }
114
+
115
+ // ── 搜索结果折叠成树(移植 core lib/search-tree.ts,一比一)──
116
+ // 目录在前文件在后、逐层字母排序、重复路径合并——结果读起来像真实文件树。
117
+ export interface SearchTreeNode {
118
+ name: string;
119
+ /** 相对路径,如 "components/App.tsx" 或 "components" */
120
+ path: string;
121
+ isDir: boolean;
122
+ children: SearchTreeNode[];
123
+ }
124
+ export function buildSearchTree(paths: string[]): SearchTreeNode[] {
125
+ const roots: SearchTreeNode[] = [];
126
+ const byPath = new Map<string, SearchTreeNode>();
127
+ for (const relative of paths) {
128
+ const segments = relative.split("/");
129
+ let current = roots;
130
+ let currentPath = "";
131
+ for (let i = 0; i < segments.length; i++) {
132
+ currentPath = currentPath ? currentPath + "/" + segments[i] : segments[i];
133
+ let node = byPath.get(currentPath);
134
+ if (!node) {
135
+ node = { name: segments[i], path: currentPath, isDir: i < segments.length - 1, children: [] };
136
+ byPath.set(currentPath, node);
137
+ current.push(node);
138
+ }
139
+ current = node.children;
140
+ }
141
+ }
142
+ const sort = (nodes: SearchTreeNode[]) => {
143
+ nodes.sort((a, b) => (a.isDir !== b.isDir ? (a.isDir ? -1 : 1) : a.name.localeCompare(b.name)));
144
+ nodes.forEach((n) => sort(n.children));
145
+ };
146
+ sort(roots);
147
+ return roots;
148
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * deploy 脚本共享「部署状态 commit」助手(pi-web-ext 各扩展 tools/deploy.mjs 统一用)。
3
+ *
4
+ * 背景(2026-08-30 排障,session 0cc6ad47 op264):重复部署同内容时
5
+ * `git add -A && git commit` 以 "nothing to commit" exit 1,而 stdio:"ignore" 吞掉
6
+ * stderr,输出「提交失败(忽略):Command failed: …」假报警(msg-smart 实证);
7
+ * 另有 --allow-empty 族每次部署都造空 commit 噪音。本助手统一三态语义:
8
+ * committed → 有暂存变更,已提交
9
+ * clean → 无变更,跳过(不算失败)
10
+ * failed → git 真失败(带 stderr 首行,不阻断部署——部署本体永远优先)
11
+ *
12
+ * 用法:
13
+ * import { deployCommit } from "../../_shared/deploy-commit.mjs";
14
+ * deployCommit({ repo: extRepo, name: "aiti-msg-smart", paths: ["aiti-msg-smart"],
15
+ * log: (m) => console.log("[deploy] " + m) });
16
+ *
17
+ * 红线:本助手永不 throw、不触碰部署产物路径之外的任何东西;只对 <repo> 做
18
+ * add/diff/commit(默认 --no-verify,extRepo 无 hooks,防未来误挂)。
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ export function deployCommit({ repo, name, paths = null, extraMsg = "", ident = false, log = console.log }) {
23
+ const git = (args) =>
24
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
25
+ const firstLine = (s) => String(s || "").trim().split("\n")[0];
26
+ try {
27
+ // 暂存:默认只收本扩展子目录(与 add <ext> 行为对齐;避免把别的扩展的
28
+ // 未提交改动卷进本扩展的部署 commit);paths=null 时退回 add -A 全仓。
29
+ git(["add", ...(paths && paths.length ? ["--", ...paths] : ["-A"])]);
30
+ let dirty;
31
+ try {
32
+ git(["diff", "--cached", "--quiet"]);
33
+ dirty = false; // exit 0 = 暂存区与 HEAD 一致
34
+ } catch (e) {
35
+ if (e.status !== 1) throw e; // 1 = 有变更;其他才是真错误
36
+ dirty = true;
37
+ }
38
+ if (!dirty) {
39
+ log("扩展仓库无变化(跳过提交)");
40
+ return { status: "clean" };
41
+ }
42
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 19);
43
+ const msg = `[deploy] ${name} · ${stamp}${extraMsg ? " · " + extraMsg : ""}`;
44
+ git([...(ident ? ["-c", "user.name=deploy", "-c", "user.email=deploy@local"] : []), "commit", "--no-verify", "-m", msg]);
45
+ log("扩展仓库已提交:" + msg);
46
+ return { status: "committed", msg };
47
+ } catch (e) {
48
+ const reason = firstLine(e.stderr) || firstLine(e.message) || "git error";
49
+ log("提交失败(忽略,不影响部署):" + reason);
50
+ return { status: "failed", reason };
51
+ }
52
+ }
package/dev-pack.mjs ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * pi-web-ext · dev-pack 共享实现(方案 §3.2 / M5:扩展 dev-pack——src + 构建工具 + AGENTS.md 直达部署目录)
3
+ *
4
+ * 目标:用户在 AI 窗对 AI 说「帮我改一下 xxx 扩展」时,AI 能在**部署目录**直接:
5
+ * 读 AGENTS.md → 改 src/ → node tools/build.mjs → curl reload → 浏览器生效,不等回开发机。
6
+ *
7
+ * 各扩展 tools/deploy.mjs 末尾统一追加一段(见本文件同级各 deploy.mjs 模式):
8
+ * const { packDevPack } = await import("../../_shared/dev-pack.mjs");
9
+ * packDevPack({ extRoot: <扩展源码根>, log: console.log });
10
+ *
11
+ * packDevPack 向每个已存在的部署目录(~/.pi/web{,-dev}/extensions/<extId>)写入:
12
+ * AGENTS.md 源码目录有则拷,无则由模板生成(§3.4)
13
+ * src/ client/src/**、根级服务端源码 *.ts/*.cjs/*.mjs、lib/(跳 vendor)、server/、sched/、worker-src/
14
+ * tools/build.mjs 现场构建入口(扩展自带则拷真身,否则生成 esbuild 默认版)
15
+ * tools/deploy.mjs 部署脚本本身(大改后回源码仓重部署的入口)
16
+ * package.json 含 aitiConfigs 声明与 build 脚本
17
+ * vite.config.ts 有则拷
18
+ *
19
+ * 幂等:重复部署整目录覆盖;允许失败:任何异常只 log 不影响 deploy 退出码(调用方 try/catch)。
20
+ */
21
+ "use strict";
22
+ import { homedir } from "node:os";
23
+ import { join, resolve, dirname, basename, sep as pathSep } from "node:path";
24
+ import {
25
+ existsSync, readdirSync, statSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync,
26
+ } from "node:fs";
27
+
28
+ // 根级服务端源码文件:拷进 src/(运行时平铺文件在部署根不动,宿主 loader 路径不变)
29
+ const ROOT_SRC_EXTS = new Set([".ts", ".tsx", ".cjs", ".mjs"]);
30
+ const ROOT_SRC_SKIP = new Set(["dist", "node_modules"]);
31
+ // 源码子目录:整目录拷(vendor 类大资产跳过——部署目录本就有运行时副本)
32
+ const SRC_DIRS = ["client/src", "server", "lib", "sched", "worker-src"];
33
+ const DIR_SKIP = new Set(["vendor", "__pycache__", "node_modules"]);
34
+
35
+ function copyTree(src, dest, skip) {
36
+ mkdirSync(dest, { recursive: true });
37
+ for (const en of readdirSync(src, { withFileTypes: true })) {
38
+ if (skip && skip.has(en.name)) continue;
39
+ const s = join(src, en.name), d = join(dest, en.name);
40
+ if (en.isDirectory()) copyTree(s, d, skip);
41
+ else if (en.isFile()) cpSync(s, d);
42
+ }
43
+ }
44
+
45
+ /** AGENTS.md 默认模板(§3.4;扩展自带 AGENTS.md 则不走到这里)。供 CLI 批量生成源码目录版复用。 */
46
+ export function agentsMdTemplate(extId) {
47
+ return `# ${extId} 扩展(部署目录直改指南)
48
+
49
+ ## 结构
50
+ - 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
51
+ - \`src/\` 源码(client/src + 服务端 ts/cjs)· \`dist/\` 构建产物(禁手改)· \`tools/\` 构建/部署脚本
52
+ - npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
53
+
54
+ ## 小改现场流程(改文案/样式/小逻辑)
55
+ 1. 改 \`src/\` 下对应源码
56
+ 2. \`node tools/build.mjs\`(client 有构建产物的扩展;纯服务端改动跳过)
57
+ 3. \`curl -X POST http://127.0.0.1:3001/api/extensions/reload\`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → \`/xxx/api/extensions/reload\`)
58
+ 4. 浏览器刷新页面验证
59
+
60
+ ## 大改回源码仓
61
+ - 源码仓:\`extensions/${extId}/\`(git 管理,deploy 同步回本目录)
62
+ - 改完在源码仓跑 \`node tools/deploy.mjs\`(或仓库根 \`node scripts/deploy-all.mjs --only ${extId}\`)
63
+
64
+ ## 数据目录(数据分层:config / cache)
65
+ - \`ctx.configDir\` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
66
+ - \`ctx.cacheDir\`(= \`<configDir>/cache/\`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
67
+ - \`ctx.dataDir\` 已移除(访问即抛)——新代码禁用
68
+ - 本扩展白名单(永不为 cache 的配置文件)见 package.json \`aitiConfigs.configFiles\`
69
+
70
+ ## 红线
71
+ - 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
72
+ - 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
73
+ - 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
74
+ - 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
75
+ `;
76
+ }
77
+
78
+ /** 默认 tools/build.mjs(esbuild 单包零配置;扩展自带 tools/build.mjs 则不走到这里)。 */
79
+ function buildMjsTemplate(extId, extRoot, clientOut, entryRel) {
80
+ const repoHint = "/home/pi/workspace/pi-work"; // 现场构建探测源码仓 node_modules 用(三机各不同,缺失时走 npm exec)
81
+ return `/**
82
+ * ${extId} · 现场构建入口(dev-pack 默认模板,esbuild 单包零配置)
83
+ * 用法:node tools/build.mjs
84
+ * 本文件由 dev-pack 生成;复杂构建(多入口/插件链)请参照 src/vite.config.ts 改写本文件或回源码仓构建。
85
+ */
86
+ "use strict";
87
+ import { existsSync, readdirSync, constants as fsConstants, accessSync } from "node:fs";
88
+ import { join, resolve, dirname, basename, sep as pathSep } from "node:path";
89
+ import { fileURLToPath } from "node:url";
90
+ import { spawnSync } from "node:child_process";
91
+
92
+ const here = dirname(fileURLToPath(import.meta.url));
93
+ const extRoot = resolve(here, "..");
94
+ const clientOut = ${JSON.stringify(clientOut || "dist/client-bundle.mjs")};
95
+ const entryRel = ${JSON.stringify(entryRel || "")};
96
+
97
+ // esbuild 解析:部署目录 node_modules → 源码仓 node_modules(本机开发场景)→ PATH 上的 esbuild → npm exec 现拉。
98
+ // 注意:node_modules/esbuild/bin/esbuild 是原生二进制(ELF/Mach-O),必须直接执行(不能当 JS 跑)。
99
+ function findEsbuild() {
100
+ const home = (process.env.HOME || process.env.USERPROFILE || "").split(pathSep).join("/");
101
+ const candidates = [
102
+ extRoot + "/node_modules/esbuild/bin/esbuild",
103
+ home + "/workspace/pi-work/node_modules/esbuild/bin/esbuild",
104
+ "D:/workspace/pi-work/node_modules/esbuild/bin/esbuild", // 155 win(源码在 D 盘)
105
+ ];
106
+ for (const c of candidates) {
107
+ if (!existsSync(c)) continue;
108
+ try { accessSync(c, fsConstants.X_OK); return [c]; } catch (e) { /* 无执行位 → 下一个来源 */ }
109
+ }
110
+ const r = spawnSync("esbuild", ["--version"], { encoding: "utf8" });
111
+ if (r.status === 0) return ["esbuild"];
112
+ return ["npm", "exec", "--yes", "esbuild", "--"];
113
+ }
114
+
115
+ function pickEntry() {
116
+ if (entryRel) return entryRel;
117
+ const srcDir = join(extRoot, "src", "client", "src");
118
+ if (!existsSync(srcDir)) throw new Error("找不到 client 源码目录 src/client/src");
119
+ for (const name of ["entry.tsx", "entry.ts", "entry.jsx", "entry.js", "index.tsx", "index.ts"]) {
120
+ if (existsSync(join(srcDir, name))) return "client/src/" + name;
121
+ }
122
+ const first = readdirSync(srcDir).find((f) => /\\.(tsx?|jsx?)$/.test(f));
123
+ if (!first) throw new Error("client/src 下没有可构建入口");
124
+ return "client/src/" + first;
125
+ }
126
+
127
+ // 服务端源码同步:src/ 根级文件(index.ts/core.cjs/package.json/vite.config.ts 等)→ 部署根
128
+ // (服务端没有构建产物——jiti 直接加载部署根文件;不 sync 则「改 src → reload」永远不生效)
129
+ import { readdirSync as _rd, copyFileSync as _cp, statSync as _st } from "node:fs";
130
+ const srcRoot = join(extRoot, "src");
131
+ if (existsSync(srcRoot)) {
132
+ for (const f of _rd(srcRoot)) {
133
+ const fp = join(srcRoot, f);
134
+ if (!_st(fp).isFile()) continue;
135
+ _cp(fp, join(extRoot, f));
136
+ }
137
+ console.log("[build] 服务端源码已从 src/ 同步到部署根");
138
+ }
139
+
140
+ const entry = pickEntry();
141
+ const args = [join(extRoot, "src", entry), "--bundle", "--format=esm",
142
+ "--outfile=" + join(extRoot, clientOut), "--jsx=automatic"];
143
+ // pi-web host-shim 委托(react → 本地 shim;jsx-runtime 同理)——与各扩展 vite.config 同款
144
+ const shim = join(extRoot, "src", "client", "src", "host-shim.ts");
145
+ const jsxRuntime = ["jsx-runtime.ts", "jsx-runtime.tsx", "jsx-dev-runtime.ts"].map((f) => join(extRoot, "src", "client", "src", f)).find(existsSync);
146
+ if (existsSync(shim)) args.push("--alias:react=" + shim);
147
+ if (jsxRuntime) args.push("--alias:react/jsx-runtime=" + jsxRuntime);
148
+ args.push("--log-level=info");
149
+
150
+ const [cmd, ...pre] = findEsbuild();
151
+ console.log("[build] esbuild:", cmd, ...pre);
152
+ const r = spawnSync(cmd, [...pre, ...args], { stdio: "inherit", cwd: extRoot });
153
+ if (r.status !== 0) { console.error("[build] 失败——可参照 src/vite.config.ts 调整本文件后重试,或回源码仓构建"); process.exit(r.status ?? 1); }
154
+ console.log("[build] ✓ " + clientOut + "(reload 后浏览器刷新生效)");
155
+ `;
156
+ }
157
+
158
+ /**
159
+ * 主入口:向已存在的部署目录写 dev-pack。
160
+ * @param {object} opts
161
+ * @param {string} opts.extRoot 扩展源码根(pi-web-ext/<extId>)
162
+ * @param {string[]} [opts.targets] 显式部署目录;缺省 = ~/.pi/web{,-dev}/extensions/<extId>(存在的才写)
163
+ * @param {(m: string) => void} [opts.log]
164
+ */
165
+ export function packDevPack(opts) {
166
+ const extRoot = resolve(opts.extRoot);
167
+ const extId = basename(extRoot);
168
+ const log = opts.log || (() => {});
169
+ const targets = (opts.targets || [
170
+ join(homedir(), ".pi", "web", "extensions", extId),
171
+ join(homedir(), ".pi", "web-dev", "extensions", extId),
172
+ ]).filter((t) => existsSync(t));
173
+ if (!targets.length) return;
174
+
175
+ // 1) 打包源码到临时区(staging)→ 逐目标 cpSync(多部署目录零重复扫描)
176
+ const stage = join(extRoot, "dist", ".dev-pack-staging");
177
+ rmSync(stage, { recursive: true, force: true });
178
+ mkdirSync(stage, { recursive: true });
179
+ try {
180
+ // src/:根级源码文件 + 源码子目录
181
+ const srcDir = join(stage, "src");
182
+ mkdirSync(srcDir, { recursive: true });
183
+ for (const en of readdirSync(extRoot, { withFileTypes: true })) {
184
+ if (en.isDirectory()) continue;
185
+ if (ROOT_SRC_SKIP.has(en.name)) continue;
186
+ if (!ROOT_SRC_EXTS.has(en.name.slice(en.name.lastIndexOf(".") || 0)) && en.name !== "package.json") continue;
187
+ cpSync(join(extRoot, en.name), join(srcDir, en.name));
188
+ }
189
+ for (const d of SRC_DIRS) {
190
+ const s = join(extRoot, d);
191
+ if (!existsSync(s)) continue;
192
+ copyTree(s, join(srcDir, ...d.split("/")), DIR_SKIP);
193
+ }
194
+ for (const f of ["vite.config.ts", "package.json"]) {
195
+ if (existsSync(join(extRoot, f))) cpSync(join(extRoot, f), join(srcDir, f));
196
+ }
197
+ // tools/build.mjs:扩展自带拷真身,否则生成默认 esbuild 版
198
+ mkdirSync(join(stage, "tools"), { recursive: true });
199
+ if (existsSync(join(extRoot, "tools", "build.mjs"))) {
200
+ cpSync(join(extRoot, "tools", "build.mjs"), join(stage, "tools", "build.mjs"));
201
+ } else {
202
+ let clientOut = "";
203
+ try {
204
+ const pkg = JSON.parse(readFileSync(join(extRoot, "package.json"), "utf8"));
205
+ if (pkg.client && String(pkg.client).startsWith("dist/")) clientOut = pkg.client;
206
+ } catch (e) {}
207
+ if (!clientOut) {
208
+ // 回退:client 产物名常声明在 entry 的 client: "dist/<id>-client.mjs"(index.ts)——从 staging 的 src 里解析
209
+ try {
210
+ for (const f of readdirSync(srcDir)) {
211
+ if (!/\.tsx?$/.test(f)) continue;
212
+ const m = readFileSync(join(srcDir, f), "utf8").match(/client\s*:\s*["'`](dist\/[\w.-]+\.mjs)["'`]/);
213
+ if (m) { clientOut = m[1]; break; }
214
+ }
215
+ } catch (e) {}
216
+ }
217
+ writeFileSync(join(stage, "tools", "build.mjs"), buildMjsTemplate(extId, extRoot, clientOut, ""));
218
+ }
219
+ // tools/deploy.mjs 本身
220
+ if (existsSync(join(extRoot, "tools", "deploy.mjs"))) {
221
+ cpSync(join(extRoot, "tools", "deploy.mjs"), join(stage, "tools", "deploy.mjs"));
222
+ }
223
+ // AGENTS.md:自带优先,否则模板生成
224
+ if (existsSync(join(extRoot, "AGENTS.md"))) cpSync(join(extRoot, "AGENTS.md"), join(stage, "AGENTS.md"));
225
+ else writeFileSync(join(stage, "AGENTS.md"), agentsMdTemplate(extId));
226
+
227
+ // 1.5) _shared 依赖:部署 src 里的源码可能相对引用 ../../../_shared/*(源仓出仓路径)——
228
+ // 部署根放一份 _shared(小体量、纯源码),让现场 build 的模块解析与源仓一致
229
+ const sharedSrc = resolve(extRoot, "..", "_shared");
230
+ if (existsSync(sharedSrc)) {
231
+ const sharedStage = join(stage, "_shared");
232
+ copyTree(sharedSrc, sharedStage, DIR_SKIP);
233
+ }
234
+
235
+ // 2) 落位(staging → 部署目录;package.json/vite.config.ts 双放:根级按 §3.2 结构,src 内供现场阅读)
236
+ for (const t of targets) {
237
+ cpSync(stage, t, { recursive: true });
238
+ for (const f of ["package.json", "vite.config.ts"]) {
239
+ if (existsSync(join(stage, f))) cpSync(join(stage, f), join(t, f));
240
+ }
241
+ }
242
+ log(`dev-pack → ${targets.map((t) => t.replace(homedir(), "~")).join(", ")}(AGENTS.md + src/ + tools/)`);
243
+ } finally {
244
+ rmSync(stage, { recursive: true, force: true });
245
+ }
246
+ }
@@ -0,0 +1,4 @@
1
+ import { ak as f } from "./entry-e6L3SQB9.js";
2
+ export {
3
+ f as default
4
+ };