diff --git a/.gitattributes b/.gitattributes index f07182edc5..0006bb96e5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,6 @@ # Generated diffs encode an empty context line as one significant space. # Keep other whitespace diagnostics enabled; source-file checks are unchanged. patches/*.patch whitespace=-blank-at-eol + +# Keep the vendored changelog byte-identical to its pinned upstream source. +apps/desktop/resources/plugins/pi.file-manager/CHANGELOG.md whitespace=-blank-at-eol diff --git a/apps/desktop/electron/main/bootstrap/remote-hosts.ts b/apps/desktop/electron/main/bootstrap/remote-hosts.ts index 95fd2e3ca0..10eb2d4c4f 100644 --- a/apps/desktop/electron/main/bootstrap/remote-hosts.ts +++ b/apps/desktop/electron/main/bootstrap/remote-hosts.ts @@ -8,6 +8,7 @@ import { type RemoteHostConnectionStatus, type RemoteHostSyncProvidersRequest, type RemoteHostSyncProvidersResult, type SessionSummary, type CommandShellOption, isCommandShellOption, type WorkspaceFilesRequest, + type WorkspacePluginFilesRequest, } from "@pi-desktop/shared"; import { wsClientTransport } from "@pi-desktop/racp"; import type { HostRpcPort } from "@pi-desktop/agent-host"; @@ -70,6 +71,7 @@ export interface RemoteHostsBoot { abortWorkspaceTool(request: { hostKey: string; sessionId: string; toolCallId: string }): Promise; workspaceCommandShell(hostKey: string): Promise; workspaceFiles(hostKey: string, input: WorkspaceFilesRequest): Promise; + workspacePluginFiles(hostKey: string, input: WorkspacePluginFilesRequest): Promise; readonly registry: RemoteHostRegistry; } let activeRemoteHostsBoot: RemoteHostsBoot | null = null; @@ -423,6 +425,14 @@ export function createRemoteHostsBoot(options: BootRemoteHostsOptions): RemoteHo if (connectedHost(hostKey) !== host) throw unavailable(); return result; }, + async workspacePluginFiles(hostKey, input) { + await ensureReady(hostKey); + const host = connectedHost(hostKey); + if (!host) throw unavailable(); + const result = await host.adapter.client.request("workspace/pluginFiles", input); + if (connectedHost(hostKey) !== host) throw unavailable(); + return result; + }, async workspaceCommandShell(hostKey) { await ensureReady(hostKey); const host = connectedHost(hostKey); diff --git a/apps/desktop/electron/main/plugin-host-process.mjs b/apps/desktop/electron/main/plugin-host-process.mjs index 89c91c41d3..35143e5fac 100644 --- a/apps/desktop/electron/main/plugin-host-process.mjs +++ b/apps/desktop/electron/main/plugin-host-process.mjs @@ -284,13 +284,13 @@ function buildApi() { ...result, bytes: normalizeBytes(result?.bytes), })), - readPreview: (path) => call("fs.readPreview", [path]), + readPreview: (path, options) => call("fs.readPreview", options === undefined ? [path] : [path, options]), openDefault: (path) => call("fs.openDefault", [path]), openInBrowser: (path) => call("fs.openInBrowser", [path]), reveal: (path) => call("fs.reveal", [path]), writeText: (path, content) => call("fs.writeText", [path, content]), glob: (pattern) => call("fs.glob", [pattern]), - list: (path) => call("fs.list", [path]), + list: (path, options) => call("fs.list", options === undefined ? [path] : [path, options]), remove: (path) => call("fs.remove", [path]), requestDirectory: () => call("fs.requestDirectory"), }, diff --git a/apps/desktop/electron/main/plugin-remote-files.ts b/apps/desktop/electron/main/plugin-remote-files.ts new file mode 100644 index 0000000000..9eecc451dc --- /dev/null +++ b/apps/desktop/electron/main/plugin-remote-files.ts @@ -0,0 +1,67 @@ +import * as Value from "typebox/value"; +import { WorkspacePluginFilesResultSchema, + type WorkspacePluginFilesRequest, type WorkspacePluginFilesResult } from "@pi-desktop/shared"; +import type { PluginFsReadOptions } from "@pi-desktop/plugin-sdk"; + +export type PluginRemoteFilesRequest = Omit & { workspacePath: string }; +export type PluginRemoteFilesRead = (request: PluginRemoteFilesRequest) => Promise; + +const failure = (code: string, message: string) => Object.assign(new Error(message), { code }); +export function pluginFsReadOptions(value: unknown): PluginFsReadOptions | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") throw failure("INVALID_ARGUMENT", "workspacePath must be a string"); + return { workspacePath: value }; +} +export function assertLocalPluginRoot(root: string | null): void { + if (root?.startsWith("remote-project:")) { + throw failure("UNSUPPORTED", "This file operation does not support remote workspaces"); + } +} + +/** Keep remote file admission and validation outside the plugin lifecycle orchestrator. */ +export async function readRemotePluginFiles(context: { + workspacePath: string | null; + currentWorkspacePath: () => string | null; + isActive: () => boolean; + scope: readonly string[]; + read?: PluginRemoteFilesRead; + audit: (entry: Record) => void; +}, operation: "list" | "preview", ref: string, options?: PluginFsReadOptions): Promise { + const root = context.workspacePath; + if (options !== undefined && (!options || typeof options !== "object" || typeof options.workspacePath !== "string")) { + throw failure("INVALID_ARGUMENT", "workspacePath must be a string"); + } + if (options && options.workspacePath !== root) throw failure("WORKSPACE_CHANGED", "The file view's workspace has changed"); + if (!root?.startsWith("remote-project:")) return null; + const api = operation === "list" ? "fs.list" : "fs.readPreview"; + try { + if (typeof ref !== "string" || /[\u0000-\u001f]/.test(ref) || /^(?:[\\/]|[a-zA-Z]:)/.test(ref)) { + throw failure("INVALID_ARGUMENT", "Remote plugin paths must be root-relative"); + } + const parts = ref.replaceAll("\\", "/").split("/").filter(part => part && part !== "."); + if (parts.includes("..")) throw failure("INVALID_ARGUMENT", "Remote plugin paths must stay inside the workspace"); + const normalizedRef = parts.join("/"); + if (!context.read) throw failure("UNSUPPORTED", "Remote plugin file access is unavailable; upgrade Desktop and pi-host"); + const result = await context.read({ workspacePath: root, operation, ref: normalizedRef, scope: [...context.scope] }); + if (!context.isActive()) throw failure("PLUGIN_UNLOADED", "The plugin was unloaded during the file request"); + if (context.currentWorkspacePath() !== root) throw failure("WORKSPACE_CHANGED", "The file view's workspace has changed"); + if (!Value.Check(WorkspacePluginFilesResultSchema, result) + || (operation === "list") !== (result.kind === "list")) { + throw failure("INVALID_RESPONSE", "Remote Host returned an invalid plugin file response"); + } + if (result.kind === "list") { + for (const entry of result.entries) { + const expected = normalizedRef ? `${normalizedRef}/${entry.name}` : entry.name; + if (/[\\/\u0000-\u001f]/.test(entry.name) || [".", ".."].includes(entry.name) || entry.path !== expected) { + throw failure("INVALID_RESPONSE", "Remote Host returned an invalid directory entry"); + } + } + } + context.audit({ api, ok: true, path: normalizedRef, remote: true, ts: Date.now() }); + return result; + } catch (error) { + const record = error as { code?: string; errorCode?: string }; + context.audit({ api, ok: false, path: ref, remote: true, errorCode: record.errorCode ?? record.code ?? "UNKNOWN", ts: Date.now() }); + throw error; + } +} diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index dca5d870fc..43d730b36d 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -14,6 +14,7 @@ import { randomUUID } from "node:crypto"; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { pluginNetFetch, parseFetchRedirect, type PluginFetchTransport } from "./plugin-net-fetch"; import type { LoadedSkillDocument } from "./skill-document"; +import { assertLocalPluginRoot, pluginFsReadOptions, readRemotePluginFiles, type PluginRemoteFilesRead } from "./plugin-remote-files"; import { getModuleDirectory } from "./module-path"; import { busTopicAllowed, @@ -28,6 +29,7 @@ import { normalizeFsPath, parseNetDomains, parseSkillFrontmatter, + PLUGIN_FS_LIST_MAX_ENTRIES, pluginMcpToolKey, pluginSkillId, pluginThemeId, @@ -56,6 +58,7 @@ import { type PluginFsMode, type PluginFsPolicy, type PluginFsRule, + type PluginFsReadOptions, type PluginLlmContext, type PluginManifest, type PluginModelInfo, @@ -325,6 +328,7 @@ export type PluginHostServices = { * visible workspace -- for a panel call or an unknown session. */ getWorkspacePathForSession?: (sessionId: string) => string | null; + readRemoteWorkspaceFiles?: PluginRemoteFilesRead; /** The set of `contributes.agentExtensions` modules changed (load/unload). */ agentExtensionsChanged?: () => void; getLocale?: () => string; @@ -799,8 +803,6 @@ export const MAX_COMPLETE_SYSTEM_CHARS = 32 * 1024; export const MAX_COMPLETE_MESSAGE_CHARS = 200_000; /** Kept from the pre-scope implementation: a listing is not a search index. */ const MAX_GLOB_MATCHES = 500; -/** Entries returned for one directory. A tree is walked lazily, not dumped. */ -const MAX_LIST_ENTRIES = 1000; /** Maximum bytes one plugin range call may cross the broker with. */ const MAX_FS_READ_RANGE_BYTES = 8 * 1024 * 1024; /** @@ -2503,7 +2505,7 @@ export class PluginRuntime { context?.droppedPath, ); case "fs.readPreview": - return api.fs.readPreview(String(payload?.path ?? "")); + return api.fs.readPreview(String(payload?.path ?? ""), pluginFsReadOptions(payload?.workspacePath)); case "fs.openDefault": await api.fs.openDefault(String(payload?.path ?? "")); return { ok: true }; @@ -2519,7 +2521,7 @@ export class PluginRuntime { case "fs.glob": return api.fs.glob(String(payload?.pattern ?? "*")); case "fs.list": - return api.fs.list(String(payload?.path ?? "")); + return api.fs.list(String(payload?.path ?? ""), pluginFsReadOptions(payload?.workspacePath)); // Deleting is not reachable from a panel; choosing a directory is, and // has to be, because a "pick a folder" button is panel UI by nature. case "fs.requestDirectory": @@ -4983,6 +4985,33 @@ export class PluginRuntime { return scoped ?? this.services.getWorkspacePath(); } + private remoteFsRead(loaded: LoadedPlugin, operation: "list" | "preview", ref: string, options?: PluginFsReadOptions) { + this.assertPermission(loaded, "fs.read"); + const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] }; + return readRemotePluginFiles({ + workspacePath: this.fsRoot(loaded, rule), currentWorkspacePath: () => this.fsRoot(loaded, rule), + isActive: () => this.loaded.get(loaded.manifest.id) === loaded, + scope: rule.scope, read: this.services.readRemoteWorkspaceFiles, + audit: (entry) => this.services.audit?.({ ...entry, pluginId: loaded.manifest.id }), + }, operation, ref, options); + } + + private assertFileWorkspace(loaded: LoadedPlugin, options?: PluginFsReadOptions): void { + if (!options) return; + if (this.loaded.get(loaded.manifest.id) !== loaded) throw apiError("PLUGIN_UNLOADED", "The plugin was unloaded during the file request"); + const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] }; + if (this.fsRoot(loaded, rule) !== options.workspacePath) throw apiError("WORKSPACE_CHANGED", "The file view's workspace has changed"); + } + + private captureFileWorkspace(loaded: LoadedPlugin, options?: PluginFsReadOptions): PluginFsReadOptions { + this.assertPermission(loaded, "fs.read"); + this.assertFileWorkspace(loaded, options); + const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] }; + const workspacePath = this.fsRoot(loaded, rule); + if (!workspacePath) throw apiError("NOT_FOUND", "No workspace is open"); + return { workspacePath }; + } + /** * Resolve one file request and decide whether it may proceed. * @@ -5038,6 +5067,7 @@ export class PluginRuntime { } const rule: PluginFsRule = loaded.fsPolicy[mode] ?? { root: "workspace", scope: [] }; const root = this.fsRoot(loaded, rule); + assertLocalPluginRoot(root); if (!root) { throw apiError( "NOT_FOUND", @@ -5115,6 +5145,7 @@ export class PluginRuntime { loaded: LoadedPlugin, requestPath: string, ): Promise<{ full: string; rel: string; root: string } | null> { + assertLocalPluginRoot(this.fsRoot(loaded, loaded.fsPolicy.read ?? { root: "workspace", scope: [] })); if (!isAbsolute(String(requestPath ?? ""))) return null; const rule: PluginFsRule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] }; if (rule.root !== "workspace") return null; @@ -5831,7 +5862,11 @@ export class PluginRuntime { await handle.close().catch(() => {}); } }, - readPreview: async (pathFromRoot: string) => { + readPreview: async (pathFromRoot: string, options?: PluginFsReadOptions) => { + const admitted = this.captureFileWorkspace(loaded, options); + const remote = await this.remoteFsRead(loaded, "preview", pathFromRoot, admitted); + if (remote && remote.kind !== "list") return remote; + this.assertFileWorkspace(loaded, admitted); const { full, rel } = await this.resolveFsRequest( loaded, "read", @@ -5849,6 +5884,7 @@ export class PluginRuntime { throw apiError("INVALID_ARGUMENT", "only files can be previewed"); } const preview = await previewFile(full, rel); + this.assertFileWorkspace(loaded, admitted); this.services.audit?.({ pluginId, api: "fs.readPreview", @@ -6000,11 +6036,15 @@ export class PluginRuntime { * paths, denied names, skipped heavy directories — because a listing is * a read: what a plugin may not open, it may not learn the name of. */ - list: async (pathFromRoot: string) => { + list: async (pathFromRoot: string, options?: PluginFsReadOptions) => { + const admitted = this.captureFileWorkspace(loaded, options); + const remote = await this.remoteFsRead(loaded, "list", pathFromRoot, admitted); + if (remote?.kind === "list") return remote.entries; + this.assertFileWorkspace(loaded, admitted); this.assertPermission(loaded, "fs.read"); const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] }; - const root = this.fsRoot(loaded, rule); - if (!root) throw apiError("NOT_FOUND", "No workspace is open"); + const root = admitted.workspacePath; + assertLocalPluginRoot(root); const rel = normalizeFsPath(String(pathFromRoot ?? "")); if (rel.split("/").includes("..")) { throw apiError("INVALID_ARGUMENT", "path must stay inside the root"); @@ -6028,7 +6068,7 @@ export class PluginRuntime { mtimeMs?: number; }> = []; for (const name of names.sort()) { - if (entries.length >= MAX_LIST_ENTRIES) break; + if (entries.length >= PLUGIN_FS_LIST_MAX_ENTRIES) break; const childRel = rel ? `${rel}/${name}` : name; const full = join(dir, name); if (isDeniedFsPath(childRel)) continue; @@ -6091,6 +6131,7 @@ export class PluginRuntime { this.assertPermission(loaded, "fs.read"); const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] }; const root = this.fsRoot(loaded, rule); + assertLocalPluginRoot(root); if (!root) throw apiError("NOT_FOUND", "No workspace is open"); const matches: string[] = []; const visit = (dir: string, rel = "") => { diff --git a/apps/desktop/electron/main/remote/plugin-workspace-files.ts b/apps/desktop/electron/main/remote/plugin-workspace-files.ts new file mode 100644 index 0000000000..cf56ef1db9 --- /dev/null +++ b/apps/desktop/electron/main/remote/plugin-workspace-files.ts @@ -0,0 +1,31 @@ +import type { ProjectGroupRecord, WorkspacePluginFilesRequest } from "@pi-desktop/shared"; +import type { PluginRemoteFilesRead } from "../plugin-remote-files"; + +type Host = { call(method: string, input?: unknown): Promise }; +type Boot = { workspacePluginFiles(hostKey: string, input: WorkspacePluginFilesRequest): Promise }; +const failure = (code: string, message: string) => Object.assign(new Error(message), { code }); + +/** The plugin supplies an admitted identity; only durable Host-owned records choose the machine. */ +export function createPluginWorkspaceReader(deps: { + getHost: () => Host | null; + getBoot: () => Boot | null; +}): PluginRemoteFilesRead { + return async ({ workspacePath, operation, ref, scope }) => { + const owner = deps.getHost(); const boot = deps.getBoot(); + if (!owner || !boot) throw failure("HOST_UNAVAILABLE", "Remote workspace Host is unavailable"); + const currentBinding = async () => { + const { groups } = await owner.call<{ groups: ProjectGroupRecord[] }>("project.groups.list"); + if (deps.getHost() !== owner || deps.getBoot() !== boot) throw failure("HOST_UNAVAILABLE", "Workspace Host was replaced"); + const remote = groups.find(group => group.primaryPath === workspacePath)?.remote; + if (!workspacePath.startsWith("remote-project:") || !remote) throw failure("NOT_FOUND", "Remote workspace binding is unavailable"); + return { hostKey: remote.hostKey, path: remote.path }; + }; + const binding = await currentBinding(); + const result = await boot.workspacePluginFiles(binding.hostKey, { path: binding.path, operation, ref, scope }); + const next = await currentBinding(); + if (binding.hostKey !== next.hostKey || binding.path !== next.path) { + throw failure("WORKSPACE_CHANGED", "Remote workspace binding changed during the file request"); + } + return result; + }; +} diff --git a/apps/desktop/electron/main/remote/racp-remote-host-client.ts b/apps/desktop/electron/main/remote/racp-remote-host-client.ts index 37dfcba3c5..4ab994b3bc 100644 --- a/apps/desktop/electron/main/remote/racp-remote-host-client.ts +++ b/apps/desktop/electron/main/remote/racp-remote-host-client.ts @@ -208,7 +208,13 @@ export function createRacpRemoteHostClient( async request(method: string, params?: unknown, timeoutMs?: number): Promise { // Offline errors remain offline errors; capabilities belong to the live handshake. if (racp.state === "connected") { - if (method === "workspace/files") requireWorkspace("workspaceFiles"); + if (method === "workspace/pluginFiles") { + if (!racp.initialized?.capabilities.workspacePluginFiles) { + throw Object.assign(new Error("Upgrade pi-host to a matching Desktop release with plugin file browsing support"), { + errorCode: ErrorCodes.HOST_VERSION_MISMATCH, retriable: false, + }); + } + } else if (method === "workspace/files") requireWorkspace("workspaceFiles"); else if (["workspace/toolExecute", "workspace/toolAbort", "workspace/toolShell"].includes(method)) { requireWorkspace("workspaceTools"); } diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index cdd62ef139..7a10d0c719 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -1,5 +1,7 @@ import { dialog, globalShortcut, shell, type BrowserWindow } from "electron"; import { join } from "node:path"; +import { getActiveRemoteHostsBoot } from "../bootstrap/remote-hosts"; +import { createPluginWorkspaceReader } from "../remote/plugin-workspace-files"; import { IPC, ErrorCodes, @@ -706,6 +708,7 @@ export function createPluginServices({ browserHost.setChromeSurface(surface); }; plugins.setServices({ + readRemoteWorkspaceFiles: createPluginWorkspaceReader({ getHost, getBoot: getActiveRemoteHostsBoot }), /** * The richer workspace payload, so `pi.workspace.get` and the * `workspace:changed` event both expose the open project's folder roots diff --git a/apps/desktop/electron/main/workspace-roots.ts b/apps/desktop/electron/main/workspace-roots.ts index 0a7e30b66a..6ffb568525 100644 --- a/apps/desktop/electron/main/workspace-roots.ts +++ b/apps/desktop/electron/main/workspace-roots.ts @@ -119,6 +119,13 @@ export async function refreshProjectGroups( */ export function pluginWorkspaceInfo(path: string | null): PluginWorkspaceInfo | null { if (!path) return null; + if (path.startsWith("remote-project:")) { + const group = groups?.find((candidate) => candidate.primaryPath === path); + if (group?.remote) return { + path, name: group.name, ...workspaceRootsFor(path), + remote: { path: group.remote.path, readOnly: true }, + }; + } const name = path.split(/[\\/]/).filter(Boolean).at(-1) || path; return { path, name, ...workspaceRootsFor(path) }; } diff --git a/apps/desktop/resources/plugins/pi.file-manager/CHANGELOG.md b/apps/desktop/resources/plugins/pi.file-manager/CHANGELOG.md new file mode 100644 index 0000000000..603cd6a230 --- /dev/null +++ b/apps/desktop/resources/plugins/pi.file-manager/CHANGELOG.md @@ -0,0 +1,352 @@ +# Changelog + +## Unreleased + +- Fix SSH workspaces remaining on Loading: remote directories and text/image + previews now use the paired Host's scoped SDK reads, with explicit read-only + controls, upgrade/errors and retry. Local file operations never receive an + opaque remote project identity. +- Discard late previews, ancestor-directory opens and refresh loops after a + workspace change. Restore local editor detection, Browser and watch + capabilities when returning from a remote project. +- Show root and nested directory failures with their actual error and Retry + action, rather than incorrectly showing Loading indefinitely. + +- HTML、HTM 与 XHTML 文件的「通过…打开」菜单新增「在内置浏览器中打开」,仅在宿主提供 Browser API 时显示;与宿主声明的 `fs.read` 文件范围一致。 +- 重新整理目录树右键菜单的分组与顺序,并补齐右键刷新;空白区域只显示新建和刷新,文件与文件夹按类型显示操作。 +- 新增 **PDF 只读预览**:连续上下滚动,支持「单页 / 双页 / 双页(封面单独)」布局、上一页 / 下一页、输入页码跳转、10%–400% 手动缩放、适应宽度、100% 实际大小,以及有文字层时的选择与复制。 +- 新增可收起的 **导航侧栏**:按可见范围渲染的缩略图与当前页标记、可展开的嵌套目录 / 书签(支持显式与命名目标)、文件名 / 体积 / 页数及 PDF Info / XMP 元数据;无内置目录时明确提示,PDF 页码标签与物理页码同时显示。窄面板中侧栏覆盖显示,跳转后自动收起,Escape 可关闭;界面跟随中英文与亮暗主题。 +- PDF.js 兼容构建、Worker、CMaps、标准字体、WASM 解码器与 ICC 色彩资源全部随包内置,支持离线 `file://` 视图;随构建输出第三方许可声明。PDF 独立设置 **24 MiB** 上限,需要密码、损坏、空文件与超限文件均显示明确提示。 +- 适应宽度按当前布局中已知最宽页面组的实际尺寸计算,兼容横竖混排、旋转与特殊页面单位;滚动切页保持比例,独立封面和末尾落单页不会触发反复缩放与渲染。面板或侧栏尺寸变化时重新适应,仅收集已经初始化页面的尺寸,兼容惰性加载。 +- 主视图由 PDF.js 管理邻近页面渲染与缓存,缩略图复用其清理事件释放主缓存外的页资源;限制画布像素,切换文件、加载失败或离开查看器时清理文档、监听器、Worker 与 Blob URL。返回缓存页时同步加载状态,快速切页后缩放保留最新阅读位置。 +- 新增 `verify:pdf` 并纳入 `verify`,覆盖二进制传递、大小边界、路径限制、切换文件夹、稳定页面组宽度、混合尺寸和惰性 / 乱序加载;查看器路由校验补充 PDF 只读类型。浏览器验收覆盖导航与内容显示、交界滚动和拖动稳定性,以及错误恢复和资源释放。 +- PDF 全文查找复用匹配版本的 `PDFFindController`,支持中英文命中高亮、计数、前后匹配、Enter / Shift+Enter 与阅读区内 Ctrl/Cmd+F;清空、关闭、重载或切换文档时重置查找。输入法组合按键保留候选词操作,普通输入沿用 PDF.js 的防抖。 +- PDF 查找按钮使用 SVG 放大镜,明确图标尺寸并随工具栏字号缩放;保留窄面板换行与可访问标签,避免字体字符过小或因系统字体而改变外观。 +- PDF 全文查找新增区分大小写、全字匹配与正则表达式开关,可组合使用;无效或不支持的表达式会清除高亮并给出可访问提示,修正后恢复。 +- PDF 三种查找开关使用界面内的悬停与键盘聚焦提示,搜索框为空或清空后也能显示;中英文说明不再依赖宿主的原生 title 提示。 +- 正则匹配移入离线独立 Worker,按页串行执行,单页超时(1 秒)或超过 10 万个命中时停止并提示;清空、关闭、切换模式与文档时取消旧任务,丢弃过期结果,空闲 Worker 自动释放。 +- 修复 PDF Worker 故障及无响应时的销毁:有限等待后清理文档所属字体、滤镜、页面任务与消息监听,再终止端口和撤销 Blob URL;启动和渲染取消的等待同样受限。 +- 修复重复书签导航的文字层监听累积:每个文档只保留一个导航监听,已完成目标直接聚焦,新跳转或销毁取消旧目标。 +- 修复嵌入字体 PDF 的文字层横向度量:优先使用 PDF.js 已加载的嵌入字体,并保留词间空白的独立几何项;普通正文的选区与画布对齐得到改善,Type3 / 缺失字体继续使用 fallback。 +- 有效后续页加载失败进入既有错误与资源释放流程;打开文件的监听判断使用当前路径,恢复 PDF / 普通文本自动重读,有未保存文本时仅提示并保留编辑。 +- 新增隔离 Electron 桌面回归检查,使用临时项目与 profile、合成 PDF 和生产 `file://` 视图,覆盖查找、监听、损坏页与 Worker 故障恢复。 + +## 0.9.0 + +- **右键菜单新增三项**:**复制相对路径**、**复制绝对路径**,以及二级子菜单 + **「通过…打开」**——鼠标停在它上面就展开,列出这台机器上真的装了的程序, + 选中即用那个程序打开当前条目(文件或文件夹都行)。 +- **按文件类型过滤**:编辑器什么都能打开,**浏览器只出现在它真能渲染的网页类文件上** + (`.html` / `.htm` / `.shtml` / `.xhtml` / `.mhtml` / `.svg` / `.pdf` / `.xml`)。 + 拿 Chrome 打开 `App.tsx` 只会看到一个纯文本加载页——那是噪音,所以干脆不列; + 文件夹同理(浏览器打开文件夹只是一个目录列表)。这条判定是 `lib/editors.ts` 的纯函数: + 主进程只标注「这是什么程序」(`kind`:editor / browser,它才知道 Chrome 是浏览器), + 视图拿「正在右键的是哪个文件」去过滤——两边各管一半,加一款浏览器不用改两处。 +- **编辑器探测不做目录遍历。** 四类来源,每类都是 O(候选数):注册表 App Paths + (HKLM / WOW6432Node / HKCU,安装器几乎都会在这里留一条 `.exe` → 绝对路径)、 + 注册表 StartMenuInternet 的 `shell\open\command`(Chrome / Edge / Firefox / Zen + 这类浏览器只在这里注册)、PATH 上按候选命令名找可执行文件、以及少数由用户主目录 + 推导出来的固定安装位置各一次 `stat`。「遍历磁盘找软件」是这个需求最容易写出来的 + 错误版本,也正好是最慢的那个。 +- **一天只探一次,多开共享。** 结果按**日期**(本地时区)缓存到插件数据目录的 + `editors.json`:同一台机器同一天里,第二个窗口、第二次启动都直接读这份缓存, + 不会再跑一遍注册表。写盘失败只是退回进程内缓存;并发调用共用同一次探测。 + 注册表那几条查询并行发起,冷启实测 600ms 上下,之后都是 0ms。 +- **找不到就诚实。** 探测不到任何编辑器时,二级菜单里只留「系统默认应用」, + 绝不放一个点了没反应的条目;探测本身不可用时也只是少一层菜单。 +- **可执行文件不出主进程。** 视图只拿到 `id` 与显示名,递回来也只能是 `id`—— + 编辑器路径由主进程从自己探测的结果里查,视图指定不了要跑什么。被打开的路径先过 + 与其它通道同一套根内包含校验与凭据黑名单(`.env*` / `.ssh/` / `*.pem` / `.git/**` + 照旧拒绝);`spawn` 不经 shell,参数只有一条绝对路径;子进程 `detached` + + `stdio: ignore` + `unref`,编辑器不随面板关闭而消失,也不会吊住插件进程; + 每次打开记一条写入审计。 +- **只认真正的 `.exe`(Windows)。** `.cmd` / `.bat` shim 要经 `cmd.exe` 执行, + 把用户项目里的路径拼进命令行是多余的注入面;App Paths 与 PATH 上的编辑器几乎总能 + 给出真正的 `.exe`,所以这条限制不漏掉它们。 +- **子菜单是一等交互**:hover 展开,`→` 进入、`←` 返回上一层、`↑↓` / `Home` / `End` + 在本层移动、`Escape` 整个关掉;超出视口时翻到触发器左侧,不会跑到屏幕外。 +- 新增离线校验 `pnpm verify:editors`(注册表 / 命令行 / PATH 候选名的文本解析夹具 + + 真 `main.js` 端到端 + 第二个进程读同一份缓存的「一天一次」断言 + 视图拿不到可执行 + 文件路径的边界 + **真的启动一次编辑器进程** + 适用面纯函数 + 「`kind` 确实一路传到 + 视图、且视图走的就是那个过滤器」的接线断言)。 + +## 0.8.2 + +- **修掉「自己保存(`Ctrl+S`)仍被判成外部改动」**:0.8.0 已经记下「这个文件是我自己写的」, + 但 Windows 的递归监听在我们写盘时,还会额外给它**所在的每一层目录**各报一次 `change <目录>` + (实测:写 `src/deep/x.ts` 会报 `change src/deep` 与 `change src`;文件名并没有丢,只是名字是目录)。 + 这几笔没有进抑制表,于是每按一次 `Ctrl+S` 都可能被其中一条判成「外部改动」,刚保存的文档被重装 + 一遍——光标与滚动位置一起丢,正是 0.8.0 想防的那件事。 + 现在 `noteSelfWrite` 除了文件路径,连它每一层祖先目录一并记上(根目录自身不发这种目录事件, + 所以只记到最外层目录为止);`pnpm verify:watch` 补上这条回归(单元层 + 嵌套路径端到端): + **修复前这两条断言稳定失败,修复后连续 6 次全绿**。 +- **顺带修掉 `verify:watch` 里一处时序竞争**:上一段(外部新建 / 删除)迟到的目录事件会落进 + 本次保存的窗口,让断言偶尔红一次(实测 5 次里红 1 次)。取游标前先静默到「连着两轮都没有新变化」, + 断言从此只反映它自己要测的那一次操作。 +- 非 Windows 上这次改动是空操作(那些平台不会发出祖先目录事件),行为与 0.8.1 完全一致。 + +## 0.8.1 + +- **修掉「文件在预览模式下打开变更时,预览与差异视图重叠」**:两处结构问题合起来才让这件事 + 发生——① 「变更」原本是 tablist **之外**的独立开关(用 `aria-pressed`),与「编辑 / 预览」不互斥, + 「预览开着 + 变更开着」因此是合法组合;② 内容区按各自的布尔条件各渲染各的,Markdown 预览与 + 差异视图两个整块高的视图会同时挂载、压在一起。 +- **现在收成一处判定**(`views-src/src/lib/pane.ts`):`contentKind()` 给出内容区**唯一**该渲染的 + 那一种(变更优先级高于预览 > 字节类),`tabs()` / `activeTabKey()` 给出头部那组互斥页签。 + `EditorPane` 只按这个单值渲染——「两个整块视图同时在场」不是靠渲染层记得加条件挡住的, + 而是结构上根本没有第二个 true。 +- **「变更」现在是 tablist 里的第三个页签**(编辑 / 预览 / 变更同组):从任何一侧都能点进去, + 点「编辑」或「预览」即等于关掉变更并回到原先那一种看法;选中项由 `activeTabKey()` 保证唯一。 +- 顺带两处收口:变更页签只对**文本文件**给出(图片 / 音视频 / 数据库各自占住内容区,逐行差异 + 对它们没有意义);`tabs()` 自己会去重 mode——重复的 key 会让 React 复用出错的节点,而这种问题 + 只在某个 mode 恰好重复时才出现。 +- **回归钉在 `verify-viewers.mjs`**:全输入组合扫描断言内容视图恒为单值、十种视图都可达、 + 「变更压过预览 / 关掉变更后预览照旧」、页签组内选中项唯一。 + +## 0.8.0 + +- **文件监听(自动刷新)**:文件在本视图之外被改动——外部编辑器、`git checkout`、构建产物、另一个窗口—— + 在一秒多内被自动发现并**重新读取**,树与列表一起刷新(体积、mtime、git 徽标、新建与删除),不必再点「刷新」。 + 主进程用 `fs.watch` 对**当前基点 root** 开一只递归监听(`persistent: false`),变化去抖 250ms 后并入有界 + 游标历史(上限 400 条),视图每 1.2 秒取一次增量——宿主没有给插件「向视图推送」的通道,视图只能自己来取; + 面板不可见时跳过这一轮。 +- **三条边界**:① **没有未保存改动时**才自动重新读取打开的文件;**有未保存改动时只亮一条提示条、绝不覆盖**, + 提示条上的「重新加载」仍走既有的未保存确认框;② 本插件自己的保存(临时文件 + rename)先记一笔, + 同名事件在 1.5 秒内不算「外部改动」——否则每按一次 `Ctrl+S` 都会把自己的文档重装一遍,光标与滚动位置一起丢; + ③ 一次外部改动最多顺手刷新 8 个目录,剩下的留给下一次或手动刷新,不把面板打满。 +- **噪声与生命周期**:`.git/`、任意层级的 `node_modules/`、凭据类路径(`.env*` / `.ssh` / `*.pem`…)与本插件的 + 原子写临时文件都不上报;换基点时监听跟着换;插件卸载时监听一并关闭;运行时不支持递归 `fs.watch` 时 + 只是关掉自动刷新,手动刷新与其它功能一律不受影响。 +- **界面**:顶部新增「自动刷新」开关(可关;监听不可用时整颗按钮禁用并说明原因,不给永远没反应的入口), + 有未保存改动时的外部改动提示条;**两个手动刷新按钮原样保留**——监听只是省掉那一下点击。 +- **新增离线校验 `pnpm verify:watch`**(已挂进 `pnpm verify`):噪声过滤与自己写入的抑制、真实 `fs.watch` + 的端到端(改 / 建 / 删、幂等、抑制时限、换基点、无项目、`onUnload`),以及视图侧「重读还是提示」判定纯函数。 + +## 0.7.0 + +- **文件列表接入 git 状态**:每一行按 git 自己的心智模型给一个徽标——绿 `A` 新增(含未跟踪的新文件)、 + 琥珀 `M` 已修改、红 `D` 已删除、蓝 `R` 已重命名、紫 `U` 冲突——文件行尾再给相对上次提交的 `+N` / `−N`; + **目录上给的是整棵子树的汇总**(最值得注意的状态 + 受影响文件数),所以不展开也能看出哪个目录脏了。 + 悬停补全「是否已暂存 / 是否未跟踪」,左侧列表顶部一句话概览当前分支与各类变化的文件数。 +- **逐行查看变更内容**:打开有改动的文件后工具栏出现「变更」按钮(右键菜单里是「查看变更」),点开是 + 逐行对照的 unified diff——新增绿底、删除红底、两侧行号、hunk 头保留所在函数名;**已暂存(HEAD → 暂存区) + 与未暂存(暂存区 → 磁盘)分段标注**,未跟踪的新文件按「全文都是新增」展示,被删除的文件仍能看它删掉了什么。 + 差异视图自带刷新与「回到文件」,保存后徽标与差异立刻跟上。 +- **状态与差异来自工作区里真实的 git**:只读子命令 `status` / `diff` / `rev-parse`,不写索引、不联网、 + 不碰 `.git/**`(`GIT_OPTIONAL_LOCKS=0` / `GIT_TERMINAL_PROMPT=0` / `GIT_LITERAL_PATHSPECS=1`), + 每个子命令 5s 超时并有输出上限,结果缓存 2s 并在插件自己保存后立刻作废。`porcelain` / `numstat` / + `unified diff` 三种输出形态由插件自己解析。 +- **降级是完整的**:没有 git、不是仓库、git 超时或缺失时,所有标记与入口整体消失,列目录 / 读 / 写 / + 保存 / 删除与 0.6.0 完全一致;路径限制与凭据黑名单一条没放松(`fm.git.diff` 同样先过根内包含校验)。 +- **新增离线校验 `pnpm verify:git`**(已挂进 `pnpm verify`):纯函数夹具钉住 git 三种输出格式,加临时真仓库 + 的端到端(`fm.list` 徽标 / `fm.read` 状态 / `fm.git.diff` 具体改动行 / 越界与凭据被拒 / 保存后立刻更新 / + 非仓库降级)。本机没装 git 时端到端那层跳过。 + +## 0.6.1 + +- **修掉「Markdown 预览过窄且不随侧边栏拖动变化」**:预览正文被 `.md-body` 残留的 `max-width: 78ch` + 钉在约 78 字符的固定列宽上,拖动分栏分隔条时也纹丝不动;现改为 `max-width: 100%`,预览占满 + 编辑区全宽并实时跟随分栏宽度(无头浏览器实测:预览容器 1081px 全宽、拖动后布局正确)。 + +## 0.5.2 + +- **修掉「切了文件夹但列表没跟着换」**:宿主报的目录是正斜杠(`C:/Users/…`),而插件把记忆写盘时用 Node 的 + `path.resolve()` 存成了反斜杠(`C:\Users\…`);两边比较只做「原样」与「忽略大小写」两次比对,没有统一 + 分隔符,于是记忆匹配不上任何一个 root,包含基点退回主文件夹——视图用自己那份原样字符串还能匹配,所以 + 头部名字换了、列表与编辑器却停在主文件夹,关掉重开也记不住。现在路径比较与分隔符无关(`samePath` / + `matchRoot`,`main.js` 与 `lib/roots.ts` 同步改),**已经写坏的旧记忆也一并救回**,无需用户重新切一次。 +- 用例补上这条链路:`verify-roots.mjs` 断言分隔符不同的同一个目录算命中;`verify-jail.mjs` 的桩宿主改成 + **正斜杠**(跟真实宿主一致)并断言切换后列的是兄弟文件夹自己的文件——修复前这一节必然失败。 + +## 0.5.1 + +- **修掉换文件夹后「用默认应用打开」/「在文件夹中显示」打开错文件**:这两条通道由宿主执行、只接受 + 工作区相对路径,基点切到项目组里的兄弟文件夹时,相对路径会被宿主按**主文件夹**解析——两个文件夹 + 里有同名文件就会打开另一个(0.5.0 的缺陷)。现在基点不是主文件夹时改送绝对路径,宿主对「本项目 + 已注册文件夹根」下的绝对路径有对应的窄放行(ADR 0253);基点仍是主文件夹时行为与 0.5.0 / 0.4 + 完全一致。 +- **换文件夹会把编辑器一起清干净**:树、展开、选中、搜索与已打开的文件整体重来,编辑器缓冲区也随 + 文件关闭而清空(面板显示空态时不再留着上一个文件夹的文档),未保存的改动照旧先问过才切。 + +## 0.5.0 + +- **项目组多文件夹**:宿主可以把一个项目注册成多个本地文件夹(`workspace.get()` 与首次握手带上 `roots` / + `projectId`)。面板左上角的文件夹名成了切换器:组的文件夹多于一个时点开就是这组 folder 的菜单 + (标出主文件夹、当前在看的那个打勾),键盘可达、带 `aria-haspopup` / `aria-expanded`,Escape 与点到 + 外面都能关;只有一个文件夹(或老宿主不送组信息)时头部与 0.4.0 完全一样,不留点了没反应的入口。 +- **看的是哪个 folder 按项目记住**:键 `p:`(宿主没给 projectId 时用 `r:<主根路径>`),值必须 + 命中当前 roots 才被信任、否则退回主根,最多 20 条、按最近写入淘汰;新项目从主文件夹开始。 +- **包含基点始终只有一个 root**:树 / 读 / 写 / 新建 / 重命名 / 移动 / 搜索 / SQLite 一律相对当前选中的 + 那个 root 解析,`..`、符号链接与 junction 逃逸、凭据黑名单一条都没放松;落在同项目另一个 root 下的 + 绝对路径会先把基点切过去,再按「相对该 root」解析。 +- **切换会重建视图状态**(树、展开、选中、搜索、已打开的文件),并照旧先过「未保存改动」确认——取消就 + 什么都不换。 + +## 0.4.0 + +- **宿主驱动的打开请求**:入口 URL 的 `?piViewOpen=` 载荷与 `view:open` 推送两条路径都接住(挂载时同步读 + 一次,加载完成后用于实时更新;加载完成前宿主改写 URL 重新加载),同一条请求重复投递只处理一次。项目 + 内路径照旧展开并加载祖先目录,脏缓冲守卫照旧生效。 +- **项目外的绝对路径也能打开与保存**:宿主自己选定的会话临时目录 / 附件路径带 `external` 标记,插件对它 + 不做根内包含校验——它本来就在根外——凭据类黑名单照旧在原始、规范化与 realpath 三态全量生效。 +- **左侧文件列表可整块收起**:宿主请求打开文件时自动收起并持久化,分栏宽度保留、展开即恢复,工具栏新增 + 键盘可达的切换按钮。 + +## 0.3.1 + +- **「用默认应用打开」与「在文件夹中显示」**:文件右键即可(走宿主的 `fs.openDefault` / `fs.reveal`)。 + 这两个动作只能由宿主代为执行,因此 manifest 首次声明 `fs.read`(root `workspace`、scope `**`) + ——与内置 Files 视图逐项相同;插件自身的读写仍走自己进程内的 Node fs,网关不介入。 + 目录对这两个动作无效(宿主返回 `INVALID_ARGUMENT`),所以只对文件提供。 + +## 0.3.0 + +**从「只能编辑文本」扩到「看得见各种文件」**:图片、音视频、分隔符文本、JSON 各自有了 +自己的查看方式;剩下的二进制(zip / apk / exe)明确说「不支持预览」,不再含糊成 +「疑似二进制」。 + +- **图片查看**:png / jpg / jpeg / gif / webp / avif / bmp / ico / svg。默认适应窗口, + 可切「实际大小」(超出即滚动),底部显示像素尺寸与体积,舞台铺棋盘格让透明区域可见。 + 字节由主进程读成 **data URI** 随响应带过来——面板是 `file://` 的沙箱页,没有文件系统, + 相对路径只会指回视图自己,这也是本插件以前「检测得到图片但显示不出来」的根因。 +- **音视频播放**:mp4 / m4v / mov / webm / ogv / mkv 用 `