From 0f58f878c40ee8c378a4500a3abb48e8f1fea846 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Fri, 23 Oct 2026 14:00:49 +0800 Subject: [PATCH 01/50] fix(windows): open focused files with configurable F4 command --- ...-10-05-project-tree-selection-and-moves.md | 24 ++ .../features/file-open-selected.json | 31 ++ .../file-explorer-selection.test.tsx | 58 +++- .../components/file-explorer-tree.tsx | 19 ++ .../components/file-navigator-sidebar.tsx | 12 + .../f4-file-navigation.integration.test.tsx | 279 ++++++++++++++++++ .../components/global-search-buffer.tsx | 27 ++ .../components/global-search-results.tsx | 6 + .../components/search-excerpt-results.tsx | 12 + .../keymaps/commands/command-registry.ts | 8 + .../keymaps/defaults/default-keymaps.ts | 1 + .../keymaps/hooks/use-file-open-target.ts | 37 +++ .../src/features/keymaps/hooks/use-keymaps.ts | 19 +- windows/tauri/src/i18n/locale.ts | 2 + 14 files changed, 531 insertions(+), 4 deletions(-) create mode 100644 shared/platform-feature-matrix/features/file-open-selected.json create mode 100644 windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx create mode 100644 windows/tauri/src/features/keymaps/hooks/use-file-open-target.ts diff --git a/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md b/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md index e4d49db3ff..d988997683 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md +++ b/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md @@ -18,6 +18,15 @@ macOS 使用系统多文件拖动,工作区模块继续负责实际文件操 ## 决策 +- Windows 的“打开所选文件”命令为 `file.openSelected`,默认 F4,沿用现有快捷键设置的 + 覆盖与禁用规则。当前获得 DOM 焦点(页面里真正接收键盘输入的元素)的区域提供打开动作, + 项目树仍持有焦点路径,搜索仍持有匹配行列,不把这些状态再复制到快捷键 store。 + 项目树打开有效的可见文件并固定标签页;目录、失效路径、重命名及树搜索输入框不执行。 + 内容搜索的查询框打开当前键盘选中的匹配,结果文件行与片段打开自身文件及一基行列位置; + 搜索等待期间、空结果、替换及过滤输入框、编辑器和终端不借用其他区域的选择。 + 编辑器开启 Vim 时,这些文件区域仍能使用 F4。区域卸载即注销动作,弹窗通过真实 DOM + 焦点隔离。例如搜索选中了 A,但焦点在 B 的文件行时,应打开 B;不要读取侧栏旧路径而打开 A。 + - 文件、目录、项目根和多选右键菜单复用已有项目范围查找与替换入口,沿用整个项目范围、 选中文字预填和既有替换预览/确认流程;树视图只提供入口,实际查找和替换由已有功能处理。 @@ -77,6 +86,8 @@ macOS 使用系统多文件拖动,工作区模块继续负责实际文件操 ## 考虑过的备选方案 +- 在项目树和搜索框分别硬编码 F4:改绑和禁用后仍可能触发,也会与全局捕获监听冲突, + 因此只在集中命令目录注册一次,复用各区域已有的文件打开动作。 - 监听侧栏活动路径来跟随编辑器:该路径同时接受目录点击,无法区分树选择与编辑器切换, 会把刚选中的目录改回编辑文件;改为复用同步 Hook 已有的编辑器目标,不另建缓冲区选择器。 - 在树菜单单独实现搜索或直接替换文件:会绕过已有搜索状态和替换预览/确认流程,不采用。 @@ -89,6 +100,9 @@ macOS 使用系统多文件拖动,工作区模块继续负责实际文件操 ## 后果 +F4 不新增文件解析、导航历史或原生调用;代价是文件区域需要注册和清理自己的焦点动作。 +本次只补齐 Windows,macOS 的 F4 行为仍需独立实现与验收。 + 独立选择减少误解,目录移动不需要枚举磁盘后代,懒加载避免为屏幕外每行启动图标任务。 代价是维护一个原生输入适配器;真实拖动、上下滚动及大目录性能仍需 macOS 实机验收。 Windows 现已加入 Ctrl/Shift 可见范围多选、批量复制/剪切/删除及已选集合拖动,目录单击选择、双击/箭头展开。拖动预检重名和父子重复,底层用不覆盖发布防止同名文件丢失;逐项操作并在工作区切换后停止后续项。Windows 原生 WebView2 的拖动、焦点与大树验收仍 pending,不能用选择工具函数测试代替。 @@ -96,6 +110,11 @@ Windows 现已加入 Ctrl/Shift 可见范围多选、批量复制/剪切/删除 ## 验证 +- Windows F4 回归使用 `test-stability-windows.ps1 -Scope Frontend` 运行 + `file-explorer-selection.test.tsx`、`f4-file-navigation.integration.test.tsx`、 + `use-keymaps.integration.test.tsx` 与 `tree-dom-event.test.tsx`。 + 验证方向键后的目标、搜索一基行列、焦点文件与旧选择不一致、改绑与禁用、Vim 设置、 + 输入框和无效目标隔离;真实 WebView2 焦点及四项修复的完整 Windows 构建另行验收。 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'ProjectTree|batchMove'` - `./scripts/verify-service-boundaries.sh` @@ -129,6 +148,11 @@ Windows 现已加入 Ctrl/Shift 可见范围多选、批量复制/剪切/删除 ## 适用范围 +- `windows/tauri/src/features/keymaps/hooks/use-file-open-target.ts` +- `windows/tauri/src/features/global-search/components/global-search-buffer.tsx` +- `windows/tauri/src/features/global-search/components/global-search-results.tsx` +- `windows/tauri/src/features/global-search/components/search-excerpt-results.tsx` +- `windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx` - `windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx` - `windows/tauri/src/features/file-explorer/hooks/use-file-explorer-sync.ts` - `windows/tauri/src/features/file-explorer/components/file-explorer-reveal.test.tsx` diff --git a/shared/platform-feature-matrix/features/file-open-selected.json b/shared/platform-feature-matrix/features/file-open-selected.json new file mode 100644 index 0000000000..65bdee9bb5 --- /dev/null +++ b/shared/platform-feature-matrix/features/file-open-selected.json @@ -0,0 +1,31 @@ +{ + "id": "file-open-selected", + "area": "工作区", + "group": "项目浏览", + "capability": "F4 打开焦点文件与搜索位置", + "macos": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/keymaps/hooks/use-file-open-target.ts", + "windows/tauri/src/features/keymaps/commands/command-registry.ts", + "windows/tauri/src/features/keymaps/defaults/default-keymaps.ts", + "windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx", + "windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx", + "windows/tauri/src/features/global-search/components/global-search-buffer.tsx", + "windows/tauri/src/features/global-search/components/search-excerpt-results.tsx", + "windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx", + "windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx", + ".agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md" + ] + }, + "owner": "Workspace", + "verification": "Windows WebView2 中在项目树选择文件后按 F4,确认固定打开该文件;目录、空树、失效项、重命名和树搜索输入不误开。内容搜索查询框用方向键选择匹配后按 F4,确认保留一基行列;焦点移到另一文件行或片段后打开该文件自身的位置。查询等待、空结果、替换和过滤框、编辑器、终端以及弹窗不得借用旧目标。设置中改绑或禁用打开所选文件,确认默认 F4 随之覆盖;编辑器开启 Vim 后文件区域仍可执行。完成四项修复后的完整 Windows 构建与真实焦点验收前保留 pending;macOS 需补齐同等命令后独立验证。" +} diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx index ea2e5ed6ad..8019e3657d 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx @@ -16,10 +16,15 @@ import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { workspaceRuntimeRegistry as registry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { LocaleProvider } from "@/i18n/locale-provider"; import { installHappyDom } from "@/test-utils/happy-dom"; +import { useKeymaps } from "@/features/keymaps/hooks/use-keymaps"; +import { openFocusedFile } from "@/features/keymaps/hooks/use-file-open-target"; +import { registerDefaultKeymaps } from "@/features/keymaps/defaults/register-defaults"; +import { keymapRegistry } from "@/features/keymaps/utils/registry"; import { FileExplorerPane } from "./file-explorer-pane"; import * as sidebar from "@/ui/sidebar"; const originalSidebar = { ...sidebar }; +const originalHandleFileOpen = useFileSystemStore.getState().handleFileOpen; // The tree owns selection; this controlled input isolates the popover's presentation lifecycle. const SearchInput = forwardRef< HTMLInputElement, @@ -77,16 +82,29 @@ function setEditorPath(path?: string) { useBufferStore.setState({ activeBufferId: path ?? null, buffers: path - ? [createPaneContent(path, { type: "editor", path, name: path.split("/").pop()!, content: "" })] + ? [ + createPaneContent(path, { + type: "editor", + path, + name: path.split("/").pop()!, + content: "", + }), + ] : [], }); } -async function renderTree(editorPath?: string) { +function FileNavigationKeymaps() { + useKeymaps(); + return null; +} + +async function renderTree(editorPath?: string, enableKeymaps = false) { await act(async () => { setEditorPath(editorPath); root.render( + {enableKeymaps ? : null} , ); @@ -167,6 +185,8 @@ beforeEach(async () => { afterEach(async () => { await act(async () => root.unmount()); + keymapRegistry.clear(); + useFileSystemStore.setState({ handleFileOpen: originalHandleFileOpen }); container.remove(); for (const spy of spies.splice(0)) spy.mockRestore(); mock.module("@/ui/sidebar", () => originalSidebar); @@ -250,6 +270,40 @@ test("search results and Enter navigation replace the previous clipboard selecti ]); }); +test("F4 opens the focused tree file permanently and ignores directories, inputs and stale targets", async () => { + const open = mock(async (_path: string, _isDir: boolean) => {}); + await act(async () => useFileSystemStore.setState({ handleFileOpen: open })); + keymapRegistry.registerCommand({ + id: "file.openSelected", + title: "Open Selected File", + execute: openFocusedFile, + }); + registerDefaultKeymaps(); + await renderTree(readme, true); + const tree = container.querySelector('[role="tree"]')!; + await act(async () => tree.focus()); + await click(directory); + await key(tree, "F4"); + expect(open).not.toHaveBeenCalled(); + await key(tree, "ArrowDown"); + await key(tree, "F4"); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenLastCalledWith(child, false); + + const input = document.createElement("input"); + tree.append(input); + await act(async () => input.focus()); + await key(input, "F4"); + expect(open).toHaveBeenCalledTimes(1); + input.remove(); + await act(async () => { + tree.focus(); + useFileSystemStore.setState({ files: [] }); + }); + await key(tree, "F4"); + expect(open).toHaveBeenCalledTimes(1); +}); + test("right-clicking a selected descendant keeps the whole selection for copy and delete", async () => { await click(directory); await click(child, true); diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx index 27b5302a20..b751219e0f 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx @@ -9,6 +9,8 @@ import { selectVisibleTreeSiblings, } from "@/features/sidebar/utils/tree-selection"; import { isTreeDomEvent } from "../lib/tree-dom-event"; +import { useFileOpenTarget } from "@/features/keymaps/hooks/use-file-open-target"; +import { isNativeTextInputTarget } from "@/utils/keyboard/text-input-target"; import ignore from "ignore"; import { EyeIcon as Eye, @@ -205,6 +207,7 @@ function FileExplorerTreeComponent({ const [treeSearchQuery, setTreeSearchQuery] = useState(""); const [debouncedTreeSearchQuery] = useDebounce(treeSearchQuery, FILE_TREE_SEARCH_DEBOUNCE_DELAY); const viewportRef = useRef(null); + const fileOpenRegionRef = useRef(null); const searchInputRef = useRef(null); const lastResolvedTreeSearchRef = useRef(null); const searchScrollTopRef = useRef(null); @@ -1212,8 +1215,24 @@ function FileExplorerTreeComponent({ return () => cancelAnimationFrame(rafId); }, [activePath]); + useFileOpenTarget(fileOpenRegionRef, (target) => { + if ( + !target.closest("#file-tree-results") || + isNativeTextInputTarget(target, target) || + isTreeSearchSearching || + !onFileOpen || + !keyboardPath + ) + return; + const index = visibleRowIndexByPath.get(keyboardPath); + const file = index === undefined ? undefined : visibleRows[index]?.file; + if (!file || file.isDir) return; + return () => onFileOpen(file.path, false); + }); + return (
void; + onOpen?: (key: string) => void; className?: string; ariaLabel?: string; viewMode?: FileNavigatorViewMode; @@ -148,6 +150,7 @@ const FileNavigatorFlatRow = memo(function FileNavigatorFlatRow({ return ( onSelect(item.key)} aria-current={isSelected ? "true" : undefined} title={title} @@ -233,6 +236,7 @@ const FileNavigatorNodeRow = memo(function FileNavigatorNodeRow({ return ( onSelect(item.key)} active={isSelected} title={item.path} @@ -255,6 +259,7 @@ export const FileNavigatorSidebar = memo(function FileNavigatorSidebar({ items, selectedKey, onSelect, + onOpen, className, ariaLabel = "", viewMode = "tree", @@ -268,6 +273,13 @@ export const FileNavigatorSidebar = memo(function FileNavigatorSidebar({ const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); const navigatorRef = useRef(null); + useFileOpenTarget(navigatorRef, (target) => { + const key = target + .closest("[data-file-navigator-key]") + ?.getAttribute("data-file-navigator-key"); + if (!onOpen || !key || !items.some((item) => item.key === key)) return; + return () => onOpen(key); + }); const [preferredWidth, setPreferredWidth] = useState(DEFAULT_FILE_NAVIGATOR_WIDTH); const [parentWidth, setParentWidth] = useState(); const [isResizing, setIsResizing] = useState(false); diff --git a/windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx b/windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx new file mode 100644 index 0000000000..947f167d28 --- /dev/null +++ b/windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useKeymaps } from "@/features/keymaps/hooks/use-keymaps"; +import { openFocusedFile } from "@/features/keymaps/hooks/use-file-open-target"; +import { keymapRegistry } from "@/features/keymaps/utils/registry"; +import { registerDefaultKeymaps } from "@/features/keymaps/defaults/register-defaults"; +import { useKeymapStore } from "@/features/keymaps/stores/keymaps.store"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { useGlobalSearchStore } from "../stores/global-search.store"; +import * as contentSearch from "../hooks/use-content-search"; +import * as excerptTokens from "../hooks/use-search-excerpt-tokens"; +import GlobalSearchBuffer from "./global-search-buffer"; + +const firstPath = "C:/fixture/f4/first.ts"; +const secondPath = "C:/fixture/f4/second.ts"; +const openFile = mock( + async (_path: string, _isDir: boolean, _line?: number, _column?: number) => "", +); +const initialFileSystem = useFileSystemStore.getState(); +const initialSettings = useSettingsStore.getState().settings; +const initialKeymaps = useKeymapStore.getState(); +const initialSearch = useGlobalSearchStore.getState(); +let search: ReturnType; +let restoreDom: () => void; +let previousAct: boolean | undefined; +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let root: Root; +let container: HTMLDivElement; +let observerDescriptor: PropertyDescriptor | undefined; +let intersectionObserverDescriptor: PropertyDescriptor | undefined; +const spies: Array<{ mockRestore(): void }> = []; + +function Navigation() { + useKeymaps(); + return ( + + + + ); +} + +async function render() { + await act(async () => root.render()); +} + +async function press(target: HTMLElement, key: string, ctrlKey = false, altKey = false) { + const event = new KeyboardEvent("keydown", { + key, + ctrlKey, + altKey, + bubbles: true, + cancelable: true, + }); + await act(async () => { + target.focus(); + target.dispatchEvent(event); + }); + return event; +} + +const queryInput = () => + container.querySelector('input[aria-label="Search in files..."]')!; + +beforeEach(async () => { + restoreDom = installHappyDom(); + spies.push(spyOn(globalThis, "requestAnimationFrame").mockReturnValue(0)); + previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + observerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver"); + intersectionObserverDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "IntersectionObserver", + ); + Object.defineProperty(globalThis, "IntersectionObserver", { + configurable: true, + value: class { + observe() {} + disconnect() {} + }, + }); + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: class { + observe() {} + disconnect() {} + }, + }); + openFile.mockClear(); + useFileSystemStore.setState({ handleFileSelect: openFile }); + useSettingsStore.setState({ + settings: { + ...initialSettings, + vimMode: false, + nativeMenuBar: false, + keybindingPreset: "none", + showFileIconsInFileTree: false, + }, + }); + useKeymapStore.setState({ keybindings: [], contexts: {} }); + useGlobalSearchStore.setState({ replaceVisible: true, scopePaths: [] }); + search = { + query: "match", + debouncedQuery: "match", + searchKey: "f4-match", + availability: "ready", + rootFolderPath: "C:/fixture/f4", + isSearching: false, + isSearchPending: false, + isLoadingMore: false, + isIndexing: false, + hasMoreResults: false, + searchedFiles: 2, + searchableFiles: 2, + indexedFiles: 2, + scannedFiles: 2, + error: null, + searchWarning: null, + includeQuery: "", + excludeQuery: "", + searchOptions: { caseSensitive: false, wholeWord: false, useRegex: false }, + setQuery() {}, + setSearchOption() {}, + setIncludeQuery() {}, + setExcludeQuery() {}, + refreshSearch: async () => {}, + loadMoreResults: async () => {}, + results: [ + { + file_path: firstPath, + total_matches: 2, + matches: [ + { line_number: 8, line_content: "match", column_start: 0, column_end: 5 }, + { line_number: 27, line_content: " match", column_start: 4, column_end: 9 }, + ], + }, + { + file_path: secondPath, + total_matches: 1, + matches: [{ line_number: 13, line_content: " match", column_start: 2, column_end: 7 }], + }, + ], + }; + spies.push(spyOn(contentSearch, "useContentSearch").mockImplementation(() => search)); + // Tokenization is independent of file navigation; prevent background syntax timers. + spies.push(spyOn(excerptTokens, "useSearchExcerptTokens").mockReturnValue([])); + keymapRegistry.registerCommand({ + id: "file.openSelected", + title: "Open Selected File", + execute: openFocusedFile, + }); + registerDefaultKeymaps(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await render(); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + keymapRegistry.clear(); + for (const spy of spies.splice(0)) spy.mockRestore(); + useFileSystemStore.setState({ handleFileSelect: initialFileSystem.handleFileSelect }); + useSettingsStore.setState({ settings: initialSettings }); + useKeymapStore.setState(initialKeymaps); + useGlobalSearchStore.setState(initialSearch); + if (observerDescriptor) Object.defineProperty(globalThis, "ResizeObserver", observerDescriptor); + else Reflect.deleteProperty(globalThis, "ResizeObserver"); + if (intersectionObserverDescriptor) + Object.defineProperty(globalThis, "IntersectionObserver", intersectionObserverDescriptor); + else Reflect.deleteProperty(globalThis, "IntersectionObserver"); + restoreDom(); + if (previousAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct; +}); + +test("F4 from the search query opens the keyboard-selected match with one-based location", async () => { + const input = queryInput(); + expect(input).not.toBeNull(); + await press(input, "ArrowDown"); + expect((await press(input, "F4")).defaultPrevented).toBe(true); + expect(openFile).toHaveBeenCalledTimes(1); + expect(openFile).toHaveBeenLastCalledWith(firstPath, false, 27, 5); + await press(input, "Enter"); + expect(openFile).toHaveBeenLastCalledWith(firstPath, false, 27, 5); +}); + +test("F4 uses the focused excerpt or file row instead of a different selected result", async () => { + const excerpt = container.querySelector('[data-excerpt-index="1"]')!; + await press(excerpt, "F4"); + expect(openFile).toHaveBeenLastCalledWith(secondPath, false, 13, 3); + const fileRow = container.querySelector( + `[data-file-navigator-key="${secondPath}"]`, + )!; + expect(fileRow).not.toBeNull(); + await press(fileRow, "F4"); + expect(openFile).toHaveBeenCalledTimes(2); + expect(openFile).toHaveBeenLastCalledWith(secondPath, false, 13, 3); +}); + +test("F4 opens a result file before its excerpt is rendered", async () => { + const matches = Array.from({ length: 40 }, (_, index) => ({ + line_number: index + 1, + line_content: "match", + column_start: 0, + column_end: 5, + })); + search = { + ...search, + results: [{ ...search.results[0], matches, total_matches: 40 }, search.results[1]], + }; + await render(); + expect(container.querySelector('[data-excerpt-index="1"]')).toBeNull(); + const fileRow = container.querySelector( + `[data-file-navigator-key="${secondPath}"]`, + )!; + expect((await press(fileRow, "F4")).defaultPrevented).toBe(true); + expect(openFile).toHaveBeenLastCalledWith(secondPath, false, 13, 3); +}); + +test("custom shortcut replaces F4 and remains scoped to a valid file target", async () => { + await act(async () => + useKeymapStore + .getState() + .actions.addKeybinding({ key: "ctrl+alt+o", command: "file.openSelected", source: "user" }), + ); + expect((await press(queryInput(), "F4")).defaultPrevented).toBe(false); + expect(openFile).not.toHaveBeenCalled(); + expect((await press(queryInput(), "o", true, true)).defaultPrevented).toBe(true); + expect(openFile).toHaveBeenCalledTimes(1); + const replacement = container.querySelector( + 'input[placeholder="Replace with..."]', + )!; + expect(replacement).not.toBeNull(); + expect((await press(replacement, "o", true, true)).defaultPrevented).toBe(false); + expect(openFile).toHaveBeenCalledTimes(1); + for (const className of ["monaco-editor", "terminal-container"]) { + const region = document.createElement("div"); + region.className = className; + const input = document.createElement("textarea"); + region.append(input); + container.append(region); + expect((await press(input, "o", true, true)).defaultPrevented).toBe(false); + region.remove(); + } + expect(openFile).toHaveBeenCalledTimes(1); + await act(async () => + useKeymapStore.getState().actions.updateKeybinding("file.openSelected", { enabled: false }), + ); + expect((await press(queryInput(), "o", true, true)).defaultPrevented).toBe(false); + expect(openFile).toHaveBeenCalledTimes(1); +}); + +test("F4 in search still works with editor Vim mode enabled", async () => { + await act(async () => + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, vimMode: true }, + }), + ); + expect((await press(queryInput(), "F4")).defaultPrevented).toBe(true); + expect(openFile).toHaveBeenLastCalledWith(firstPath, false, 8, 1); +}); + +test("pending and empty search targets and unrelated inputs do not open stale files", async () => { + const filter = container.querySelector('input[aria-label="Files to include"]')!; + expect(filter).not.toBeNull(); + expect((await press(filter, "F4")).defaultPrevented).toBe(false); + search = { ...search, isSearchPending: true }; + await render(); + expect((await press(queryInput(), "F4")).defaultPrevented).toBe(false); + search = { ...search, isSearchPending: false, results: [] }; + await render(); + expect((await press(queryInput(), "F4")).defaultPrevented).toBe(false); + expect(openFile).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/global-search/components/global-search-buffer.tsx b/windows/tauri/src/features/global-search/components/global-search-buffer.tsx index 3c12f74f7e..90b4321441 100644 --- a/windows/tauri/src/features/global-search/components/global-search-buffer.tsx +++ b/windows/tauri/src/features/global-search/components/global-search-buffer.tsx @@ -1,4 +1,5 @@ import { useGlobalSearchStore } from "../stores/global-search.store"; +import { useFileOpenTarget } from "@/features/keymaps/hooks/use-file-open-target"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import type { @@ -238,6 +239,30 @@ const GlobalSearchBuffer = ({ compact = false }: GlobalSearchBufferProps) => { ? selectedFilePath : (selectedMatch?.filePath ?? fileNavigatorItems[0]?.key ?? null); + useFileOpenTarget(inputRef, () => { + if (isResultNavigationDisabled || !selectedMatch) return; + return () => + handleFileSelect( + selectedMatch.filePath, + false, + selectedMatch.targetLine, + selectedMatch.targetColumn, + ); + }); + + const handleFileNavigatorOpen = useCallback( + (filePath: string) => { + if (isResultNavigationDisabled) return; + if (selectedMatch?.filePath === filePath) { + handleFileClick(filePath, selectedMatch.targetLine, selectedMatch.targetColumn); + return; + } + const match = results.find((result) => result.file_path === filePath)?.matches[0]; + if (match) handleFileClick(filePath, match.line_number, match.column_start + 1); + }, + [handleFileClick, isResultNavigationDisabled, results, selectedMatch], + ); + const handleFileNavigatorSelect = useCallback( (filePath: string) => { setSelectedFilePath(filePath); @@ -570,11 +595,13 @@ const GlobalSearchBuffer = ({ compact = false }: GlobalSearchBufferProps) => { fileNavigatorItems={fileNavigatorItems} selectedFileNavigatorKey={selectedFileNavigatorKey} onFileNavigatorSelect={handleFileNavigatorSelect} + onFileNavigatorOpen={handleFileNavigatorOpen} fileNavigatorViewMode={fileNavigatorViewMode} onFileNavigatorViewModeChange={setFileNavigatorViewMode} navigatorSearchResetKey={searchKey} excerpts={excerpts} selectedItemKey={selectedItemKey} + isNavigationDisabled={isResultNavigationDisabled} onOpen={handleFileClick} onExpandContext={handleExpandContext} onCollapseContext={handleCollapseContext} diff --git a/windows/tauri/src/features/global-search/components/global-search-results.tsx b/windows/tauri/src/features/global-search/components/global-search-results.tsx index f57577d96d..ff95bb9920 100644 --- a/windows/tauri/src/features/global-search/components/global-search-results.tsx +++ b/windows/tauri/src/features/global-search/components/global-search-results.tsx @@ -16,11 +16,13 @@ interface GlobalSearchResultsProps { fileNavigatorItems: FileNavigatorItem[]; selectedFileNavigatorKey: string | null; onFileNavigatorSelect: (filePath: string) => void; + onFileNavigatorOpen: (filePath: string) => void; fileNavigatorViewMode: FileNavigatorViewMode; onFileNavigatorViewModeChange: (viewMode: FileNavigatorViewMode) => void; navigatorSearchResetKey: string; excerpts: SearchExcerpt[]; selectedItemKey: string | null; + isNavigationDisabled: boolean; onOpen: (filePath: string, lineNumber?: number, columnNumber?: number) => void; onExpandContext: (filePath: string) => void; onCollapseContext: (filePath: string) => void; @@ -39,11 +41,13 @@ export function GlobalSearchResults({ fileNavigatorItems, selectedFileNavigatorKey, onFileNavigatorSelect, + onFileNavigatorOpen, fileNavigatorViewMode, onFileNavigatorViewModeChange, navigatorSearchResetKey, excerpts, selectedItemKey, + isNavigationDisabled, onOpen, onExpandContext, onCollapseContext, @@ -63,6 +67,7 @@ export function GlobalSearchResults({ items={fileNavigatorItems} selectedKey={selectedFileNavigatorKey} onSelect={onFileNavigatorSelect} + onOpen={isNavigationDisabled ? undefined : onFileNavigatorOpen} ariaLabel={t("fileNavigator.searchResultFiles")} viewMode={fileNavigatorViewMode} onViewModeChange={onFileNavigatorViewModeChange} @@ -86,6 +91,7 @@ export function GlobalSearchResults({ void; @@ -19,6 +21,7 @@ interface SearchExcerptResultsProps { } interface SearchExcerptItemProps { + isNavigationDisabled: boolean; excerpt: SearchExcerpt; index: number; selectedItemKey: string | null; @@ -33,6 +36,7 @@ const SYNTAX_PREFETCH_MARGIN = "240px 0px"; const INITIAL_SYNTAX_HIGHLIGHT_COUNT = 1; function SearchExcerptItemComponent({ + isNavigationDisabled, excerpt, index, selectedItemKey, @@ -59,6 +63,10 @@ function SearchExcerptItemComponent({ onOpen(excerpt.filePath, selectedMatch.targetLine, selectedMatch.targetColumn); }, [excerpt.filePath, onOpen, selectedMatch]); + useFileOpenTarget(sectionRef, () => + !isNavigationDisabled && selectedMatch ? openTarget : undefined, + ); + const openReadonlyLocation = useCallback( ({ line, column }: { line: number; column: number }) => { const mappedLine = excerpt.lineNumberMap[line]; @@ -105,6 +113,7 @@ function SearchExcerptItemComponent({ return (
@@ -153,6 +162,7 @@ function SearchExcerptItemComponent({ const SearchExcerptItem = memo(SearchExcerptItemComponent, (previous, next) => { return ( previous.excerpt === next.excerpt && + previous.isNavigationDisabled === next.isNavigationDisabled && previous.index === next.index && previous.selectedItemKey === next.selectedItemKey && previous.onOpen === next.onOpen && @@ -164,6 +174,7 @@ const SearchExcerptItem = memo(SearchExcerptItemComponent, (previous, next) => { }); export const SearchExcerptResults = memo(function SearchExcerptResults({ + isNavigationDisabled = false, excerpts, selectedItemKey, onOpen, @@ -207,6 +218,7 @@ export const SearchExcerptResults = memo(function SearchExcerptResults({
{excerpts.map((excerpt, index) => ( unknown; +type ResolveFileTarget = (focusedElement: Element) => OpenFileAction | undefined; + +// Views retain selection and location ownership; the command only follows DOM focus. +const fileOpenTargets = new WeakMap(); + +export function useFileOpenTarget( + regionRef: RefObject, + resolveTarget: ResolveFileTarget, +): void { + const resolveRef = useRef(resolveTarget); + resolveRef.current = resolveTarget; + + useLayoutEffect(() => { + const region = regionRef.current; + if (!region) return; + fileOpenTargets.set(region, (element) => resolveRef.current(element)); + return () => { + fileOpenTargets.delete(region); + }; + }, [regionRef]); +} + +export function resolveFocusedFileOpenTarget(): OpenFileAction | undefined { + const focusedElement = document.activeElement; + if (!focusedElement) return; + for (let region: Element | null = focusedElement; region; region = region.parentElement) { + const resolver = fileOpenTargets.get(region); + if (resolver) return resolver(focusedElement); + } +} + +export async function openFocusedFile(): Promise { + await resolveFocusedFileOpenTarget()?.(); +} diff --git a/windows/tauri/src/features/keymaps/hooks/use-keymaps.ts b/windows/tauri/src/features/keymaps/hooks/use-keymaps.ts index 35f0046d28..00df959608 100644 --- a/windows/tauri/src/features/keymaps/hooks/use-keymaps.ts +++ b/windows/tauri/src/features/keymaps/hooks/use-keymaps.ts @@ -15,6 +15,7 @@ import { isTerminalAltTextInput } from "@/features/terminal/utils/terminal-keybo import { useUIState } from "@/features/window/stores/ui-state.store"; import { IS_LINUX } from "@/utils/platform"; import { useKeymapStore } from "../stores/keymaps.store"; +import { resolveFocusedFileOpenTarget } from "./use-file-open-target"; import { getEffectiveKeybindings } from "../utils/effective-keymaps"; import { isEditorKeyboardTarget } from "../utils/editor-keyboard-target"; import { resolveEffectiveKeymapContexts } from "../utils/effective-contexts"; @@ -35,7 +36,11 @@ const CHORD_TIMEOUT = 1000; // 1 second to complete chord const CLOSE_TAB_CLOSE_REQUEST_WINDOW_MS = 1000; const closeTabShortcut = parseKeybinding("cmd+w").parts[0]; const closeWindowShortcut = parseKeybinding("cmd+shift+w").parts[0]; -const INPUT_ALLOWED_COMMANDS = new Set(["file.quickOpen", "workbench.commandPalette"]); +const INPUT_ALLOWED_COMMANDS = new Set([ + "file.quickOpen", + "file.openSelected", + "workbench.commandPalette", +]); function isCloseTabShortcut(event: KeyboardEvent) { return keysMatch(eventToKey(event), closeTabShortcut); @@ -152,6 +157,7 @@ export function useKeymaps() { isEditorTarget, isTerminalTarget, }); + effectiveContexts.fileOpenTargetFocus = !!resolveFocusedFileOpenTarget(); // Prevent modifier-shortcut floods when key is held down (e.g. Cmd+R auto-repeat) if (e.repeat && (e.metaKey || e.ctrlKey || e.altKey)) { @@ -212,7 +218,12 @@ export function useKeymaps() { // Vim mode bypass - let vim handle keys without modifiers const hasModifiers = e.metaKey || e.ctrlKey || e.altKey; - if (settings.vimMode && !hasModifiers && !e.shiftKey) { + if ( + settings.vimMode && + !hasModifiers && + !e.shiftKey && + !effectiveContexts.fileOpenTargetFocus + ) { return; } @@ -241,6 +252,8 @@ export function useKeymaps() { if (isNativeTextInput && !isEditorTextarea && !isTerminalTextarea) { for (const keybinding of allKeybindings) { if (!INPUT_ALLOWED_COMMANDS.has(keybinding.command)) continue; + if (keybinding.command === "file.openSelected" && !effectiveContexts.fileOpenTargetFocus) + continue; if (!keybinding.enabled && keybinding.enabled !== undefined) continue; if (keybinding.when && !evaluateWhenClause(keybinding.when, effectiveContexts)) continue; @@ -260,6 +273,8 @@ export function useKeymaps() { // Try to match against registered keybindings for (const keybinding of allKeybindings) { + if (keybinding.command === "file.openSelected" && !effectiveContexts.fileOpenTargetFocus) + continue; if (!keybinding.enabled && keybinding.enabled !== undefined) { continue; } diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 1fa721ebd1..077ab786c6 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -4379,6 +4379,7 @@ const catalogs = { "keybindings.commands.file.new.title": "New File", "keybindings.commands.file.open.title": "Open Project", "keybindings.commands.file.quickOpen.title": "Quick Open", + "keybindings.commands.file.openSelected.title": "Open Selected File", "keybindings.commands.file.localHistory.title": "Show Local History", "keybindings.commands.terminal.new.title": "New Terminal", "keybindings.commands.terminal.close.title": "Close Terminal", @@ -9105,6 +9106,7 @@ const catalogs = { "keybindings.commands.file.new.title": "新建文件", "keybindings.commands.file.open.title": "打开项目", "keybindings.commands.file.quickOpen.title": "快速打开", + "keybindings.commands.file.openSelected.title": "打开所选文件", "keybindings.commands.file.localHistory.title": "显示本地历史", "keybindings.commands.terminal.new.title": "新建终端", "keybindings.commands.terminal.close.title": "关闭终端", From 1ec653c6b7f8c5bb37274d3251b5344365bb0174 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Fri, 23 Oct 2026 14:38:34 +0800 Subject: [PATCH 02/50] ci(windows): include focused F4 navigation regression --- .github/workflows/ci-windows.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 99db6ed2db..86d5776886 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -349,6 +349,11 @@ jobs: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-project-tree.json -- src/features/file-explorer/hooks/use-file-explorer-drag-drop.test.tsx src/features/file-explorer/components/file-explorer-selection.test.tsx src/features/file-explorer/components/file-explorer-reveal.test.tsx src/features/file-explorer/stores/directory-marks.test.ts src/features/sidebar/utils/tree-selection.test.ts src/utils/path-helpers.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test F4 focused file navigation + shell: pwsh + timeout-minutes: 2 + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-f4-file-navigation.json -- src/features/global-search/components/f4-file-navigation.integration.test.tsx + - name: Test workspace open, replace, and close lifecycle shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-workspace-lifecycle.json -- src/features/workspace/services/workspace-lifecycle.test.ts src/features/window/services/project-window-router.test.ts From 3359bb76031813ed1cfcf71fe2b06224a6a34a28 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Fri, 23 Oct 2026 16:24:41 +0800 Subject: [PATCH 03/50] test(windows): await process exit signals and own fixture cleanup --- ...-10-14-child-processes-end-with-the-ide.md | 1 + windows/tauri/src-tauri/src/run.rs | 162 ++++++++++++------ 2 files changed, 107 insertions(+), 56 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md index ca2fcc3054..a8f5967e8c 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md +++ b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md @@ -100,6 +100,7 @@ Windows 原来有五个漏洞,叠在一起导致“关闭 Lithe 后服务还 - `./scripts/test-macos.sh --filter '(MacProcessRunnerTests|ExecutionModule)'`。 - `rust/lithe-git-host/tests/process.rs`:`process_tree_terminate_ends_the_root_and_its_descendants` 启动一个会再启动后代的进程,确认 `terminate` 返回时两者都已结束;Windows 上 `dropping_a_process_tree_ends_it_like_a_crashed_host` 只丢弃句柄,确认内核结束整组。 - `windows/tauri/src-tauri/src/run.rs` 测试(仅 Windows):关闭一个窗口只结束它自己的进程树、丢弃会话等同 Lithe 崩溃、原生列表按启动顺序包含全部会话。 + 测试在停止前保留后代进程句柄,限时等待 Windows 退出信号,不以 sysinfo 中仍能查到 PID 判断存活;根进程先退出也不代表后代已完成退出。夹具用 Drop 清理本测试的 Job 与进程,断言失败同样执行。启动时仅为发现后代 PID 做最多 5 秒的进程快照轮询(10ms 间隔),不再无限等待 stdout;该原生边界是测试稳定性扫描的明确例外。 - `rust/lithe-core/src/lsp/interface/process.rs` 测试(仅 Windows):结束批处理启动的语言服务器时,脚本启动的子进程一起结束。 - `shared/fixtures/process-lifecycle/exit-plan-v1.json`、`settle-v1.json`:运行服务在退出、关闭项目、直接停止设置和崩溃恢复下的动作;macOS `ProcessLifecycleFixtureTests` 用 Swift 类型解码同一批 fixture,并覆盖提问顺序、取消和规则失败时仍询问。 - `windows/tauri/src/features/window/services/run-services-exit.test.ts`:询问、取消、停止失败后重试、直接停止、后台任务不询问、Core 不可用时询问。 diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index ca6a2dfe8e..35d5cdc482 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -3723,32 +3723,76 @@ mod tests { assert_eq!(left, run_session_key("project-a", "primary")); } - /// A Run session like `cmd -> mvn -> java`, owned the way - /// `start_reserved_process` owns one: the suspended root joins its tree - /// before ping can start. + /// Owns both the registered Job and the root handle, including on assertion + /// failure. Descendant handles identify the exact process, without PID reuse + /// or sysinfo snapshots mistaking an exited process for a running one. + #[cfg(windows)] + struct ProcessTreeFixture { + child: Child, + window: &'static str, + descendant: Option, + } + + #[cfg(windows)] + fn process_wait(handle: &impl std::os::windows::io::AsRawHandle, timeout: Duration) -> u32 { + use windows_sys::Win32::System::Threading::WaitForSingleObject; + // The borrowed handle stays owned by the fixture throughout this wait. + unsafe { WaitForSingleObject(handle.as_raw_handle(), timeout.as_millis() as u32) } + } + + #[cfg(windows)] + impl ProcessTreeFixture { + fn assert_exited(&mut self) { + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + assert_eq!( + process_wait(&self.child, RUN_STOP_TIMEOUT), + WAIT_OBJECT_0, + "root exits" + ); + assert!(self.child.try_wait().unwrap().is_some()); + assert_eq!( + process_wait( + self.descendant.as_ref().expect("descendant handle"), + RUN_STOP_TIMEOUT + ), + WAIT_OBJECT_0, + "descendant exits", + ); + } + } + + #[cfg(windows)] + impl Drop for ProcessTreeFixture { + fn drop(&mut self) { + // Closing the Job also handles panics before the normal stop call. + drop(take_owned_sessions(|key| key.window_label == self.window)); + let _ = self.child.kill(); + let _ = process_wait(&self.child, RUN_STOP_TIMEOUT); + let _ = self.child.try_wait(); + if let Some(descendant) = &self.descendant { + let _ = process_wait(descendant, RUN_STOP_TIMEOUT); + } + } + } + + /// Starts the same suspended-root ownership sequence as a Run service. + /// Child discovery is bounded; no output reader or blocking pipe can outlive + /// the fixture. The descendant handle is retained before any stop operation. #[cfg(windows)] fn spawn_owned_process_tree( - window: &str, + window: &'static str, session: &str, title: &str, - ) -> (Child, std::process::ChildStdout, u32) { + ) -> ProcessTreeFixture { + use std::os::windows::io::{FromRawHandle, OwnedHandle}; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE}; let mut command = Command::new("cmd"); command .args(["/C", "ping -n 30 127.0.0.1"]) - .stdout(Stdio::piped()); + .stdout(Stdio::null()); ProcessTree::prepare(&mut command); let mut child = command.spawn().expect("spawn service"); let tree = ProcessTree::adopt(&mut child).expect("own service tree"); - // cmd writes nothing itself; the first output byte comes from ping, so - // the descendant is running once it arrives. The pipe stays open for - // the test, otherwise ping would exit on its next write. - let mut stdout = child.stdout.take().expect("service output"); - stdout - .read_exact(&mut [0; 1]) - .expect("service descendant output"); - let descendant = *child_process_ids(child.id()) - .first() - .expect("service descendant"); sessions().lock().unwrap().insert( run_session_key(window, session), RunningSession { @@ -3761,26 +3805,36 @@ mod tests { tree, }, ); - (child, stdout, descendant) - } - - #[cfg(windows)] - fn child_process_ids(parent: u32) -> Vec { - let mut system = sysinfo::System::new(); - system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - system - .processes() - .values() - .filter(|process| process.parent() == Some(sysinfo::Pid::from_u32(parent))) - .map(|process| process.pid().as_u32()) - .collect() - } - - #[cfg(windows)] - fn process_is_alive(pid: u32) -> bool { - let mut system = sysinfo::System::new(); - system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - system.process(sysinfo::Pid::from_u32(pid)).is_some() + let mut fixture = ProcessTreeFixture { + child, + window, + descendant: None, + }; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let mut system = sysinfo::System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + if let Some(process) = system.processes().values().find(|process| { + process.parent() == Some(sysinfo::Pid::from_u32(fixture.child.id())) + }) { + // Open before terminating the Job; querying a PID afterward can + // observe a retained process object or an unrelated reused PID. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, process.pid().as_u32()) }; + assert!( + !handle.is_null(), + "open descendant: {}", + std::io::Error::last_os_error() + ); + fixture.descendant = Some(unsafe { OwnedHandle::from_raw_handle(handle) }); + return fixture; + } + assert!( + Instant::now() < deadline, + "service descendant did not start within 5 seconds" + ); + // test-stability: allow(rust-real-sleep) reason: Windows process snapshots expose no child-start event; bounded polling only discovers the fixture PID, exit assertions use kernel signals. + thread::sleep(Duration::from_millis(10)); + } } #[cfg(windows)] @@ -3788,20 +3842,21 @@ mod tests { fn window_release_ends_its_process_trees_and_keeps_other_windows() { // Regression for #1137: closing a window left Run services and their // descendants running. Another window's service must survive the close. - let (mut closed, _closed_output, closed_descendant) = - spawn_owned_process_tree("release-closed-window", "service", "Closed"); - let (mut kept, _kept_output, kept_descendant) = - spawn_owned_process_tree("release-kept-window", "service", "Kept"); + let mut closed = spawn_owned_process_tree("release-closed-window", "service", "Closed"); + let mut kept = spawn_owned_process_tree("release-kept-window", "service", "Kept"); stop_sessions( take_owned_sessions(|key| key.window_label == "release-closed-window"), RUN_STOP_TIMEOUT, ) .expect("the closed window's trees end"); - assert!(closed.wait().is_ok()); - assert!(!process_is_alive(closed_descendant)); - assert!(kept.try_wait().unwrap().is_none()); - assert!(process_is_alive(kept_descendant)); + closed.assert_exited(); + assert!(kept.child.try_wait().unwrap().is_none()); + assert_eq!( + process_wait(kept.descendant.as_ref().unwrap(), Duration::ZERO), + windows_sys::Win32::Foundation::WAIT_TIMEOUT, + "the other window's descendant remains running", + ); // A second destroy/exit pass finds nothing left and must not reap again. assert!(take_owned_sessions(|key| key.window_label == "release-closed-window").is_empty()); @@ -3811,8 +3866,7 @@ mod tests { RUN_STOP_TIMEOUT, ) .expect("the kept window's trees end"); - assert!(kept.wait().is_ok()); - assert!(!process_is_alive(kept_descendant)); + kept.assert_exited(); } #[cfg(windows)] @@ -3820,13 +3874,11 @@ mod tests { fn dropping_a_session_ends_its_tree_like_a_crashed_lithe() { // When Lithe dies, Windows closes its job handles. Dropping the session // closes the handle the same way, so no stop call is involved. - let (mut root, _output, descendant) = - spawn_owned_process_tree("crash-window", "service", "Crash"); + let mut root = spawn_owned_process_tree("crash-window", "service", "Crash"); let owned = take_owned_sessions(|key| key.window_label == "crash-window"); assert_eq!(owned.len(), 1); drop(owned); - assert!(root.wait().is_ok()); - assert!(!process_is_alive(descendant)); + root.assert_exited(); } #[cfg(windows)] @@ -3835,11 +3887,9 @@ mod tests { // Maven goals and services of closed projects have no webview store, // so the close confirmation must come from the native table. let window = "listing-window"; - let (mut first, _first_output, _) = - spawn_owned_process_tree(window, "maven", "clean install"); - let (mut second, _second_output, _) = spawn_owned_process_tree(window, "service", "Api"); - let (mut other, _other_output, _) = - spawn_owned_process_tree("listing-other-window", "service", "Other"); + let mut first = spawn_owned_process_tree(window, "maven", "clean install"); + let mut second = spawn_owned_process_tree(window, "service", "Api"); + let mut other = spawn_owned_process_tree("listing-other-window", "service", "Other"); let preparing = { let mut pending = pending_launches().lock().unwrap(); let reservation = reserve_test_launch(&mut pending, window, "prepare"); @@ -3879,7 +3929,7 @@ mod tests { .expect("listing fixtures end"); } for root in [&mut first, &mut second, &mut other] { - assert!(root.wait().is_ok()); + root.assert_exited(); } } From 0ea8392b40c01fc4205735fdb4083fcd91f29c2d Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Fri, 23 Oct 2026 16:25:03 +0800 Subject: [PATCH 04/50] fix(search): open the focused excerpt line with F4 --- ...2026-10-05-project-tree-selection-and-moves.md | 1 + .../features/file-open-selected.json | 3 ++- .../f4-file-navigation.integration.test.tsx | 14 ++++++++++++++ .../components/search-excerpt-code.tsx | 1 + .../components/search-excerpt-results.tsx | 15 ++++++++++++--- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md b/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md index d988997683..35ecda98db 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md +++ b/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md @@ -101,6 +101,7 @@ macOS 使用系统多文件拖动,工作区模块继续负责实际文件操 ## 后果 F4 不新增文件解析、导航历史或原生调用;代价是文件区域需要注册和清理自己的焦点动作。 +搜索片段内的代码行按钮以自己的映射行作为 F4 目标,匹配行保留匹配列,上下文行从第一列打开;片段标题或容器仍使用该片段的已选匹配,避免同一片段里 Tab 聚焦到另一行后跳回旧位置。 本次只补齐 Windows,macOS 的 F4 行为仍需独立实现与验收。 独立选择减少误解,目录移动不需要枚举磁盘后代,懒加载避免为屏幕外每行启动图标任务。 diff --git a/shared/platform-feature-matrix/features/file-open-selected.json b/shared/platform-feature-matrix/features/file-open-selected.json index 65bdee9bb5..3296e4e604 100644 --- a/shared/platform-feature-matrix/features/file-open-selected.json +++ b/shared/platform-feature-matrix/features/file-open-selected.json @@ -21,11 +21,12 @@ "windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx", "windows/tauri/src/features/global-search/components/global-search-buffer.tsx", "windows/tauri/src/features/global-search/components/search-excerpt-results.tsx", + "windows/tauri/src/features/global-search/components/search-excerpt-code.tsx", "windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx", "windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx", ".agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md" ] }, "owner": "Workspace", - "verification": "Windows WebView2 中在项目树选择文件后按 F4,确认固定打开该文件;目录、空树、失效项、重命名和树搜索输入不误开。内容搜索查询框用方向键选择匹配后按 F4,确认保留一基行列;焦点移到另一文件行或片段后打开该文件自身的位置。查询等待、空结果、替换和过滤框、编辑器、终端以及弹窗不得借用旧目标。设置中改绑或禁用打开所选文件,确认默认 F4 随之覆盖;编辑器开启 Vim 后文件区域仍可执行。完成四项修复后的完整 Windows 构建与真实焦点验收前保留 pending;macOS 需补齐同等命令后独立验证。" + "verification": "Windows WebView2 中在项目树选择文件后按 F4,确认固定打开该文件;目录、空树、失效项、重命名和树搜索输入不误开。内容搜索查询框用方向键选择匹配后按 F4,确认保留一基行列;焦点移到另一文件行或片段后打开该文件自身的位置;同一片段选择第 8 行后 Tab 聚焦第 27 行按钮,F4 应打开第 27 行的匹配列,标题和容器焦点仍打开已选匹配。查询等待、空结果、替换和过滤框、编辑器、终端以及弹窗不得借用旧目标。设置中改绑或禁用打开所选文件,确认默认 F4 随之覆盖;编辑器开启 Vim 后文件区域仍可执行。完成四项修复后的完整 Windows 构建与真实焦点验收前保留 pending;macOS 需补齐同等命令后独立验证。" } diff --git a/windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx b/windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx index 947f167d28..bac5371659 100644 --- a/windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx +++ b/windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx @@ -222,6 +222,20 @@ test("F4 opens a result file before its excerpt is rendered", async () => { expect(openFile).toHaveBeenLastCalledWith(secondPath, false, 13, 3); }); +test("F4 on a focused code line overrides the earlier selected match in that excerpt", async () => { + const line = container.querySelector('button[aria-label="Open line 27"]')!; + expect(line).not.toBeNull(); + expect((await press(line, "F4")).defaultPrevented).toBe(true); + expect(openFile).toHaveBeenLastCalledWith(firstPath, false, 27, 5); + // Header/section focus continues to use the independently selected match. + await press(container.querySelector('[data-excerpt-index="0"]')!, "F4"); + expect(openFile).toHaveBeenLastCalledWith(firstPath, false, 8, 1); + search = { ...search, isSearchPending: true }; + await render(); + expect((await press(line, "F4")).defaultPrevented).toBe(false); + expect(openFile).toHaveBeenCalledTimes(2); +}); + test("custom shortcut replaces F4 and remains scoped to a valid file target", async () => { await act(async () => useKeymapStore diff --git a/windows/tauri/src/features/global-search/components/search-excerpt-code.tsx b/windows/tauri/src/features/global-search/components/search-excerpt-code.tsx index 931ce973ec..c0cfe7f260 100644 --- a/windows/tauri/src/features/global-search/components/search-excerpt-code.tsx +++ b/windows/tauri/src/features/global-search/components/search-excerpt-code.tsx @@ -116,6 +116,7 @@ const SearchExcerptLine = memo(function SearchExcerptLine({ type="button" className="flex w-full min-w-0 text-left hover:bg-accent/25 focus-visible:bg-accent/35 focus-visible:outline-none" onClick={handleClick} + data-search-excerpt-line-index={lineIndex} aria-label={`Open line ${mappedLine}`} > {content} diff --git a/windows/tauri/src/features/global-search/components/search-excerpt-results.tsx b/windows/tauri/src/features/global-search/components/search-excerpt-results.tsx index 54c794d02a..a0a99647a0 100644 --- a/windows/tauri/src/features/global-search/components/search-excerpt-results.tsx +++ b/windows/tauri/src/features/global-search/components/search-excerpt-results.tsx @@ -63,9 +63,18 @@ function SearchExcerptItemComponent({ onOpen(excerpt.filePath, selectedMatch.targetLine, selectedMatch.targetColumn); }, [excerpt.filePath, onOpen, selectedMatch]); - useFileOpenTarget(sectionRef, () => - !isNavigationDisabled && selectedMatch ? openTarget : undefined, - ); + useFileOpenTarget(sectionRef, (focusedElement) => { + if (isNavigationDisabled) return; + const lineButton = focusedElement.closest("[data-search-excerpt-line-index]"); + if (lineButton) { + const lineIndex = Number(lineButton.dataset.searchExcerptLineIndex); + const mappedLine = excerpt.lineNumberMap[lineIndex]; + if (mappedLine === null || mappedLine === undefined) return; + const match = excerpt.matches.find((item) => item.targetLine === mappedLine); + return () => onOpen(excerpt.filePath, mappedLine, match?.targetColumn ?? 1); + } + return selectedMatch ? openTarget : undefined; + }); const openReadonlyLocation = useCallback( ({ line, column }: { line: number; column: number }) => { From d37e4bf15608d278d2771495575c264ce5e00532 Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 17:09:31 +0800 Subject: [PATCH 05/50] =?UTF-8?q?refactor(plugins):=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=88=B0=20lithe-plugin-docs=EF=BC=8Cfixture?= =?UTF-8?q?=20=E6=8C=89=E8=83=BD=E5=8A=9B=E6=94=B9=E5=90=8D=EF=BC=8CSDK=20?= =?UTF-8?q?=E7=A7=BB=E5=87=BA=E4=B8=BB=E4=BB=93=E5=BA=93=20(#1442)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(plugins): 契约发布到 lithe-plugin-docs,fixture 按能力改名,SDK 移出主仓库 - 契约 fixture 按演示的能力改名(文件名和 dev.lithe.fixture.* ID),同步更新 标准答案、Rust Core 测试、macOS 测试与展示副本、功能矩阵和 Agent Notes。 - 两份 schema 的 $id 改为 lithe-plugin-docs 的 raw 地址;verify-shared-contracts.sh 要求 $id 完全相等,并拒绝离开契约目录的 README 相对链接。 - 契约 README 的目录外链接改为绝对地址,说明 lithe-plugin-docs 保存完整副本, 官方 SDK 一节指向 lithe-plugin-docs。 - 删除 sdk/、SDK 端到端测试和 Java SDK 发版工作流;Rust Core 的 CI 脚本、 ci-macos.yml、CI 分类和 .gitignore 去掉 SDK 相关内容。 - AGENTS.md 新增两仓库同步规则,新增 Agent Note 记录决策,并更新 SDK 相关 Note、develop-lithe 布局表、Plugins README 和 docs/ci-builds.md。 * docs(plugins): SDK 与契约的每周检查跟踪 preview 而不是默认分支 main --- ...2026-10-17-build-system-plugin-contract.md | 6 +- ...6-10-20-host-plugin-function-invocation.md | 4 +- .../2026-10-20-plugin-backend-sdk.md | 37 +- .../2026-10-23-plugin-contract-publishing.md | 203 ++++ .../2026-10-17-build-system-provider-api.md | 6 +- .agents/skills/develop-lithe/SKILL.md | 1 - .github/workflows/ci-macos.yml | 18 - .github/workflows/release-java-plugin-sdk.yml | 81 -- .gitignore | 1 - AGENTS.md | 21 + Plugins/README.md | 6 + Plugins/mac/README.md | 4 + docs/ci-builds.md | 3 + .../BuildSystemToolWindowTests.swift | 18 +- ... => build-command-output-file.plugin.json} | 4 +- ...son => build-host-integration.plugin.json} | 4 +- ...nguageExtensionProcessLifecycleTests.swift | 2 +- .../MacBuildProviderSessionsTests.swift | 4 +- .../PluginBuildOptionPersistenceTests.swift | 2 +- .../PluginDiskInstallationTests.swift | 2 +- ...ginLanguageServerLaunchResolverTests.swift | 4 +- .../PluginMarketplaceServiceTests.swift | 6 +- .../LitheTests/PluginPackageStoreTests.swift | 4 +- .../LitheTests/PluginWebBridgeTests.swift | 2 +- .../RustBuildSystemOperationsTests.swift | 22 +- .../src/tests/build_runtime_versions.rs | 2 +- rust/lithe-core/src/tests/build_systems.rs | 106 +- .../src/tests/declarative_plugins.rs | 69 +- .../src/tests/marketplace_plugins.rs | 20 +- rust/lithe-core/tests/plugin_sdk/mod.rs | 550 --------- rust/lithe-core/tests/plugin_sdk_java.rs | 43 - rust/lithe-core/tests/plugin_sdk_node.rs | 19 - scripts/classify-ci-changes.sh | 5 - scripts/test-classify-ci-changes.sh | 4 - scripts/verify-rust-core-crates.sh | 19 +- scripts/verify-shared-contracts.sh | 16 +- sdk/java/README.md | 153 --- sdk/java/examples/sample-build/plugin.json | 87 -- sdk/java/examples/sample-build/pom.xml | 51 - .../org/example/samplebuild/SampleBuild.java | 241 ---- sdk/java/plugin-sdk/pom.xml | 41 - .../github/litheidea/plugin/sdk/Backend.java | 1062 ----------------- .../litheidea/plugin/sdk/CallContext.java | 88 -- .../litheidea/plugin/sdk/Cancellation.java | 104 -- .../plugin/sdk/CancelledException.java | 14 - .../github/litheidea/plugin/sdk/Framing.java | 138 --- .../io/github/litheidea/plugin/sdk/Json.java | 426 ------- .../litheidea/plugin/sdk/PluginError.java | 38 - .../litheidea/plugin/sdk/PluginSdkError.java | 32 - .../litheidea/plugin/sdk/Processes.java | 623 ---------- .../github/litheidea/plugin/sdk/Protocol.java | 109 -- .../litheidea/plugin/sdk/StateStore.java | 198 --- .../litheidea/plugin/sdk/BackendTest.java | 515 -------- .../github/litheidea/plugin/sdk/Fixtures.java | 133 --- .../litheidea/plugin/sdk/SupportTest.java | 370 ------ .../github/litheidea/plugin/sdk/TestHost.java | 215 ---- sdk/java/pom.xml | 77 -- sdk/node/README.md | 145 --- .../examples/sample-build/backend/main.mjs | 149 --- sdk/node/examples/sample-build/plugin.json | 49 - sdk/node/package.json | 24 - sdk/node/src/backend.mjs | 647 ---------- sdk/node/src/errors.mjs | 60 - sdk/node/src/framing.mjs | 74 -- sdk/node/src/index.d.ts | 146 --- sdk/node/src/index.mjs | 8 - sdk/node/src/process.mjs | 188 --- sdk/node/src/protocol.mjs | 92 -- sdk/node/src/state.mjs | 148 --- sdk/node/test/backend.test.mjs | 322 ----- sdk/node/test/harness.mjs | 106 -- sdk/node/test/sample-build.test.mjs | 64 - sdk/node/test/support.test.mjs | 321 ----- shared/contracts/plugins/v1/README.md | 52 +- ... => build-command-output-file.plugin.json} | 4 +- ....json => build-command-stdout.plugin.json} | 4 +- ...son => build-host-integration.plugin.json} | 4 +- .../plugins/v1/fixtures/build-plans.json | 130 +- ...uild-provider-language-server.plugin.json} | 4 +- ...plugin.json => build-provider.plugin.json} | 4 +- ...gin.json => build-static-file.plugin.json} | 4 +- .../plugins/v1/fixtures/command-plans.json | 60 +- .../v1/fixtures/invalid-manifests.json | 220 ++-- ... => language-host-integration.plugin.json} | 4 +- ...n => language-packaged-server.plugin.json} | 4 +- ...on => language-system-command.plugin.json} | 4 +- .../v1/fixtures/marketplace/index-cases.json | 12 +- .../v1/fixtures/marketplace/index.json | 4 +- .../fixtures/marketplace/install-reviews.json | 62 +- .../plugins/v1/plugin-index.schema.json | 2 +- .../plugins/v1/plugin-manifest.schema.json | 2 +- .../build-provider-refresh-and-tool.json | 3 +- .../features/build-system-plugins.json | 4 +- .../features/go-support-plugin.json | 2 +- .../features/java-support-plugin.json | 2 +- .../features/plugin-functions.json | 14 +- 96 files changed, 721 insertions(+), 8460 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md delete mode 100644 .github/workflows/release-java-plugin-sdk.yml rename macos/Tests/LitheTests/Fixtures/BuildSystems/{gradle-build.plugin.json => build-command-output-file.plugin.json} (98%) rename macos/Tests/LitheTests/Fixtures/BuildSystems/{maven-build.plugin.json => build-host-integration.plugin.json} (98%) delete mode 100644 rust/lithe-core/tests/plugin_sdk/mod.rs delete mode 100644 rust/lithe-core/tests/plugin_sdk_java.rs delete mode 100644 rust/lithe-core/tests/plugin_sdk_node.rs delete mode 100644 sdk/java/README.md delete mode 100644 sdk/java/examples/sample-build/plugin.json delete mode 100644 sdk/java/examples/sample-build/pom.xml delete mode 100644 sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java delete mode 100644 sdk/java/plugin-sdk/pom.xml delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java delete mode 100644 sdk/java/pom.xml delete mode 100644 sdk/node/README.md delete mode 100644 sdk/node/examples/sample-build/backend/main.mjs delete mode 100644 sdk/node/examples/sample-build/plugin.json delete mode 100644 sdk/node/package.json delete mode 100644 sdk/node/src/backend.mjs delete mode 100644 sdk/node/src/errors.mjs delete mode 100644 sdk/node/src/framing.mjs delete mode 100644 sdk/node/src/index.d.ts delete mode 100644 sdk/node/src/index.mjs delete mode 100644 sdk/node/src/process.mjs delete mode 100644 sdk/node/src/protocol.mjs delete mode 100644 sdk/node/src/state.mjs delete mode 100644 sdk/node/test/backend.test.mjs delete mode 100644 sdk/node/test/harness.mjs delete mode 100644 sdk/node/test/sample-build.test.mjs delete mode 100644 sdk/node/test/support.test.mjs rename shared/contracts/plugins/v1/fixtures/{gradle-build.plugin.json => build-command-output-file.plugin.json} (98%) rename shared/contracts/plugins/v1/fixtures/{go-build.plugin.json => build-command-stdout.plugin.json} (97%) rename shared/contracts/plugins/v1/fixtures/{maven-build.plugin.json => build-host-integration.plugin.json} (98%) rename shared/contracts/plugins/v1/fixtures/{mix-provider.plugin.json => build-provider-language-server.plugin.json} (93%) rename shared/contracts/plugins/v1/fixtures/{gradle-provider.plugin.json => build-provider.plugin.json} (97%) rename shared/contracts/plugins/v1/fixtures/{cargo-build.plugin.json => build-static-file.plugin.json} (97%) rename shared/contracts/plugins/v1/fixtures/{java-support.plugin.json => language-host-integration.plugin.json} (93%) rename shared/contracts/plugins/v1/fixtures/{php-support.plugin.json => language-packaged-server.plugin.json} (94%) rename shared/contracts/plugins/v1/fixtures/{go-support.plugin.json => language-system-command.plugin.json} (95%) diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index d46bac82bb..8262826890 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -35,7 +35,7 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 没有声明的能力不报错,也不会出现在界面上。例如 Cargo fixture 没有声明依赖树,Build 工具窗口就不显示依赖区。 -字段全集只写在 `shared/contracts/plugins/v1/README.md` 的 “API level 3: build systems” 一节和 `plugin-manifest.schema.json` 里,本 Note 不重复。四个 fixture(`cargo-build`、`go-build`、`gradle-build`、`maven-build`)是完整示例,`gradle-provider`、`mix-provider` 分别示范 Provider 的两种写法。 +字段全集只写在 `shared/contracts/plugins/v1/README.md` 的 “API level 3: build systems” 一节和 `plugin-manifest.schema.json` 里,本 Note 不重复。四个 fixture 各示范一种模型来源,按能力命名而不是按真实插件命名:`build-static-file`(读项目文件,内容用 Cargo)、`build-command-stdout`(解码命令输出,内容用 Go)、`build-command-output-file`(解码命令写出的文件,内容用 Gradle)、`build-host-integration`(交给宿主集成,内容用 Maven)。`build-provider`、`build-provider-language-server` 分别示范 Provider 的两种写法。 ### 2. Core 与宿主的分工 @@ -333,11 +333,11 @@ macOS 接入规则(2026-10-18): ## 验证 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems`:四个 fixture 通过校验;`build-plans.json` 的全部标准答案;在临时工作区里验证 Cargo 运行入口与内置探测器逐字一致、嵌套、优先级与拒绝、Maven reactor 识别、Gradle 规范格式输出文件、JUnit 报告、同步指纹、Maven 任务参数与内置实现一致,以及互斥条件下允许重复引用选项。 -- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems` 还覆盖 Provider:`build-plans.json` 里 `gradle-provider`、`mix-provider` 的请求计划和 `result` 解码;`provider_processes_are_install_review_programs`(自有进程和共享写法在安装审查中的条目);`launch_plans_name_exactly_one_process`;`provider_models_are_not_read_during_detection`。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems` 还覆盖 Provider:`build-plans.json` 里 `build-provider`、`build-provider-language-server` 的请求计划和 `result` 解码;`provider_processes_are_install_review_programs`(自有进程和共享写法在安装审查中的条目);`launch_plans_name_exactly_one_process`;`provider_models_are_not_read_during_detection`。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib declarative_plugins`:`invalid-manifests.json` 里的非法清单,包括社区插件声明 `hostIntegration`、未声明权限、路径越界、未保护的模板变量和选项重复放置,以及 Provider 的十种错误写法(没有来源使用、混写、缺少标志、缺少权限、入口越界、不支持的运行时、未声明的设置等),和裁剪后不再接受的九种写法(静态 `yaml`、命令 `json`、任务规则、顶层目标、`parent`、映射标签、`parentDirectory`、依赖 `children`、`layout.generated`);`command-plans.json` 里按 `buildSystemID` 选中的启动计划。 - `declarative_plugins::build_providers_require_their_own_host_capability` 与 `marketplace_plugins::build_provider_versions_require_host_support_before_download` 验证 Provider 能力与声明式构建分开,安装审查的共享 fixture 同时覆盖自有进程和共享写法。 - `cargo test -p lithe-core --lib lsp`:`lsp/interface/build_provider.rs` 验证只有宿主提供过的版本算协商成功、版本 1 只接受两个方法;`engine.rs` 的测试验证协商后的会话写出 `lithe/build/*` 并返回结果,未协商或方法不在版本里时直接失败、不写入进程。 -- `./scripts/test-macos.sh --filter 'BuildSystemServiceTests'` 的 Provider 用例:`providerModelSendsThePlannedRequestAndDecodesItsReply`、`providerDependenciesDecodeTheProvidersReply` 验证请求原样发给按插件、构建系统和工作区确定的目标,回复作为 `providerResult` 交给 Core 解码且不启动命令;`workspaceSwitchAndDisabledPluginStopTheirProviders` 验证停用插件和切换工作区停止进程、插件列表不变时保留;`providerModelFailsWithoutAProviderHost`、`failedProviderRequestReportsItsMessage` 验证失败路径。`providerModelLoadCarriesTheResolvedToolWithoutRefresh`、`providerReloadsAskForARefresh`、`providerLoadsWithoutTheToolWhenResolutionFails`、`commandModelLoadsDoNotResolveAProviderTool` 验证加载原因和工具路径:首次加载不带 `refresh`、重新加载的模型和依赖都带,工具只在 Provider 请求时解析,解析失败时请求照常发出且 Build Output 写明原因;`MacBuildProviderSessionsTests.providerHintsReachTheProcessUnchanged` 验证这两个字段原样到达进程,`PluginWebBridgeTests.pageRefreshReloadsTheModel` 验证网页的 `build.refresh` 算重新加载。`build-plans.json` 的 `gradle provider … refresh …`、`… tool must be an absolute path`、`gradle model command ignores …` 用例覆盖 Core 的写入、校验和命令来源忽略。`PluginMarketplaceServiceTests.buildProvidersAreAcceptedByTheMacOSHost` 验证 `gradle-provider`、`mix-provider` 能通过 macOS 的安装审查。`MacBuildProviderSessionsTests` 用真实的 `MacBuildProviderSessions` 和脚本化 Core 验证:重复请求复用进程,按插件停止后下一次请求重新启动;设置变化后按新参数重启;包内入口不可执行时不启动进程。`PluginPackageStoreTests.unusablePackagedBuildProviderIsRefused`、`buildProviderRuntimeEntryMustBeInThePackage` 验证安装时校验 Provider 的包内入口:缺失、不可执行、经过符号链接都被拒绝,运行时入口缺失也被拒绝。 +- `./scripts/test-macos.sh --filter 'BuildSystemServiceTests'` 的 Provider 用例:`providerModelSendsThePlannedRequestAndDecodesItsReply`、`providerDependenciesDecodeTheProvidersReply` 验证请求原样发给按插件、构建系统和工作区确定的目标,回复作为 `providerResult` 交给 Core 解码且不启动命令;`workspaceSwitchAndDisabledPluginStopTheirProviders` 验证停用插件和切换工作区停止进程、插件列表不变时保留;`providerModelFailsWithoutAProviderHost`、`failedProviderRequestReportsItsMessage` 验证失败路径。`providerModelLoadCarriesTheResolvedToolWithoutRefresh`、`providerReloadsAskForARefresh`、`providerLoadsWithoutTheToolWhenResolutionFails`、`commandModelLoadsDoNotResolveAProviderTool` 验证加载原因和工具路径:首次加载不带 `refresh`、重新加载的模型和依赖都带,工具只在 Provider 请求时解析,解析失败时请求照常发出且 Build Output 写明原因;`MacBuildProviderSessionsTests.providerHintsReachTheProcessUnchanged` 验证这两个字段原样到达进程,`PluginWebBridgeTests.pageRefreshReloadsTheModel` 验证网页的 `build.refresh` 算重新加载。`build-plans.json` 的 `gradle provider … refresh …`、`… tool must be an absolute path`、`gradle model command ignores …` 用例覆盖 Core 的写入、校验和命令来源忽略。`PluginMarketplaceServiceTests.buildProvidersAreAcceptedByTheMacOSHost` 验证 `build-provider`、`build-provider-language-server` 能通过 macOS 的安装审查。`MacBuildProviderSessionsTests` 用真实的 `MacBuildProviderSessions` 和脚本化 Core 验证:重复请求复用进程,按插件停止后下一次请求重新启动;设置变化后按新参数重启;包内入口不可执行时不启动进程。`PluginPackageStoreTests.unusablePackagedBuildProviderIsRefused`、`buildProviderRuntimeEntryMustBeInThePackage` 验证安装时校验 Provider 的包内入口:缺失、不可执行、经过符号链接都被拒绝,运行时入口缺失也被拒绝。 - `./scripts/verify-shared-contracts.sh`:schema 与 fixture 一致;`build-plans.json` 的用例名唯一、操作合法、拒绝码都有文档、不含机器路径;`command-plans.json` 的每个用例只给出 `languageID`、`buildSystemID` 之一。 - `./scripts/verify-rust-core-comments.sh`:`build/` 模块的注释规范。 - `./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests'`:选项根选择保留各实例的候选和生效值,原生菜单动作覆盖自定义单选替换、多选追加及取消已选自定义值;原生 `NSTextField` 提交验证 text/file/directory 清空后提交 `""` 而非重置。设置 `LITHE_BUILD_CAPTURE_DIR` 后可导出选项行的明暗截图。`BuildSystemServiceTests.instanceOptionEditsPreserveSiblingOnlySelections` 验证编辑一个实例不会丢掉另一个实例独有的选择;`languageImportPublishesTheSameEffectiveOptionsAsBuildPlansAndClearsDisabledPlugins` 验证插件配置与构建计划使用同一份生效值、修改后重新发布、停用及重置清理;`RustBuildSystemOperationsTests.languageImportMapsPluginOptionIDsWithoutBuildSystemNameAssumptions` 验证真实 Core 的导入映射和 Maven 可选执行字段解码。本地 Maven、Gradle fixture 副本经真实 Core 校验后读出 `icon`、`presentation`、选项图标和 `category`;模型任务自带的 `category`;每个构建系统推导出的窗口和命令(ID、标题、停靠、命令与窗口分开的动作),以及注册表校验通过。补充回归覆盖窗口所属模块激活、命令 pending 状态、独立窗口停靠与显隐恢复、旧 Maven 贡献及命令移除、参数任务根工程与目标作用范围。临时插件包中的窗口与选项明暗 SVG 加载后,目录清单和每个文件的字节必须完全不变。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md index 8f7987e6c1..0d0e0ba8d2 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md +++ b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md @@ -148,13 +148,13 @@ if pluginID == "dev.lithe.maven" { 启动带函数的会话 } // 宿主按插 - 每个 Provider 带独有的 `LITHE_PLUGIN_BACKEND_SCOPE`,会话结束后宿主按该标记优雅结束遗留进程。 - `./scripts/verify-runtime-bundle-immutability.sh`:确认没有新增写入 bundle 的路径。 - macOS 原生验收(2026-10-20,PR #1366 合并 PR #1365 后的 debug 应用): - - 环境:`sdk/node/examples/sample-build` 加一个只用于验收的网页窗口,作为开发包从磁盘安装;另装一个带自己构建系统和网页窗口的验收插件;示例工作区含成功、失败(退出码 3)和长时间运行三个项目。页面把每一步结果写进 `ui.setState`,验收读取 Application Support 中的状态文件核对。 + - 环境:Node SDK 的示例插件 `sdk/node/examples/sample-build`(当时在主仓库,现位于 lithe-plugin-docs)加一个只用于验收的网页窗口,作为开发包从磁盘安装;另装一个带自己构建系统和网页窗口的验收插件;示例工作区含成功、失败(退出码 3)和长时间运行三个项目。页面把每一步结果写进 `ui.setState`,验收读取 Application Support 中的状态文件核对。 - `functions.list` 返回 `snapshot.read`、`workspace.build`;一次成功构建后再列出,多了运行时注册的 `snapshot.clear`。 - `functions.invoke` 带 `call` 时,页面依次收到 `Preparing`、`Building app`、`Done` 三个 `functions.progress`,结果作为 `{ value }` 到达;失败项目返回 `plugin.function.failed`,并带上插件自己的 `pluginErrorCode: sample.buildFailed`。 - `functions.cancel` 后调用以 `requestCancelled` 结束;构建子进程在取消后立刻退出(采样中最后一次出现早于取消约 0.1 秒)。对已结束的 `call` 再取消返回 `{}`。未注册的函数返回 `plugin.function.unknown`,`params` 不是对象返回 `plugin.web.invalidRequest`。 - 另一个插件的页面用 `sample:.` 调用 `functions.list` / `functions.invoke` 都得到 `plugin.web.forbidden`,并且在弹同意框之前就被拒绝;它调用自己实例上的 `snapshot.read` 得到 `plugin.function.unknown`,说明函数按进程隔离。 - 进程清理:调用进行中关闭项目、或在设置里停用插件,Provider 及其构建子进程都会退出(`ps`/`pgrep` 确认);从磁盘替换插件包时旧 Provider 也会停止;退出应用后没有任何 Lithe 或插件进程残留。重启应用后 `snapshot.read` 返回上次会话的快照。 - - 验收发现示例的路径检查在宿主 `rootUri` 带尾部斜杠时拒绝所有路径(macOS 传的是目录 URL 的 `absoluteString`),所有构建都报 `sample.config`。已在示例里用 `path.resolve` 规范化根目录,并让 `rust/lithe-core/tests/plugin_sdk_node.rs` 用带尾部斜杠的 `rootUri`;未修复时该测试失败,修复后通过。 + - 验收发现示例的路径检查在宿主 `rootUri` 带尾部斜杠时拒绝所有路径(macOS 传的是目录 URL 的 `absoluteString`),所有构建都报 `sample.config`。已在示例里用 `path.resolve` 规范化根目录,并让当时主仓库的 `rust/lithe-core/tests/plugin_sdk_node.rs`(现为 lithe-plugin-docs 的 `sdk/e2e/plugin_sdk_node.rs`)用带尾部斜杠的 `rootUri`;未修复时该测试失败,修复后通过。 - `./scripts/verify-platform-feature-matrix.sh`:`plugin-functions` 的 macOS 证据已更新。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md index fd18a15874..93ad659a8c 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md +++ b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md @@ -8,7 +8,9 @@ Lithe 有两个官方插件后端 SDK:`sdk/node`(包名 `@lithe/plugin-sdk` SDK 只**实现**契约,不**定义**契约。协议仍以 `shared/contracts/plugins/v1/README.md` 为准,SDK 不新增任何线上字段;不用 SDK 手写的后端同样合法。SDK 的价值是把“不能破坏 Lithe 统一管理”的规则变成默认行为:不发 `lithe/*` 回调、stdout 只走协议、状态只写宿主给的目录、调用结束时进程树一起结束。 -两个 SDK 各有一个示例插件(`sdk/*/examples/sample-build`),在 Rust Core CI lane 里经真实 Core 会话跑**同一套**端到端场景(`rust/lithe-core/tests/plugin_sdk/mod.rs`)。所以任一 SDK 与契约或另一个 SDK 的行为出现分叉时,测试都会失败。 +两个 SDK 各有一个示例插件(`sdk/*/examples/sample-build`),经真实 Core 会话跑**同一套**端到端场景(`sdk/e2e/plugin_sdk/mod.rs`)。所以任一 SDK 与契约或另一个 SDK 的行为出现分叉时,测试都会失败。 + +2026-10-23 起,两个 SDK 的源码、发版和端到端测试都在 [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) 的 `sdk/` 下,主仓库不再保存 SDK。插件开发者只看那个仓库就能拿到契约副本、SDK 和教程。迁移原因见 [插件契约、SDK 与开发者资料的发布](2026-10-23-plugin-contract-publishing.md);下文出现的 `sdk/...` 路径都指 lithe-plugin-docs。 ## 问题 @@ -23,13 +25,15 @@ Issue #1351 的目标是给插件开发者开放官方 SDK,同时“保留创 ## 决策 -### 1. 先做 Node,放在仓库根目录 `sdk/node` +### 1. 先做 Node,SDK 单独成一个目录 -Lithe 的 `runtime` 启动方式已支持 `node` 和 `java`。Node 无需编译、单文件即可分发、启动快,最适合作为第一个 SDK。目录放在根目录 `sdk/`,与 `shared/`(契约)、`Plugins/`(具体插件包)分开:`sdk/` 是可被插件复制使用的库代码,不属于任何一个平台,也不是契约本身。 +Lithe 的 `runtime` 启动方式已支持 `node` 和 `java`。Node 无需编译、单文件即可分发、启动快,最适合作为第一个 SDK。SDK 与 `shared/`(契约)、`Plugins/`(具体插件包)分开:它是可被插件复制使用的库代码,不属于任何一个平台,也不是契约本身。最初放在主仓库根目录 `sdk/`,后来整体移到 lithe-plugin-docs 的 `sdk/`。 ### 2. 零依赖、复制进插件包使用 -插件包安装后只读、没有 `npm install` 步骤、必须能离线运行。所以 SDK 不依赖任何 npm 包,插件作者把 `src/` 复制到包内用相对路径导入。`package.json` 标 `private: true`,暂不发布到 npm;等 API 稳定后再决定发布方式。 +插件包安装后只读、没有 `npm install` 步骤、必须能离线运行。所以 SDK 不依赖任何 npm 包,插件作者把 SDK 复制到包内用相对路径导入。 + +发布方式和 Java SDK 相同:lithe-plugin-docs 打 `sdk-node-v<版本>` 标签,它的 `.github/workflows/release-node-sdk.yml` 先在 Node 20 上跑 SDK 测试,再用 `npm pack` 生成 `lithe-plugin-sdk-node-<版本>.tgz` 和 `.sha256`,附在 GitHub Release 上。插件仓库在构建时按固定版本和 SHA-256 下载、校验并解开,把 `package/` 目录放进插件包;运行时不下载任何东西。这样外部开发者不用克隆主仓库,也不用给主仓库提 PR。`package.json` 仍标 `private: true`,`npm pack` 不受影响,但 SDK 不会被误发到 npm;等 API 稳定后再决定是否发布到 npm。 ### 3. 规则变成默认行为,而不是文档里的建议 @@ -57,7 +61,7 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 - **最低 Java 8。** 很多企业项目仍在 Java 8 上构建,后端最好能直接用项目那个 JDK 启动。代价是 SDK 只能用 Java 8 的 API。 - **零运行时依赖,自带最小 JSON 读写(`Json`)。** 插件要把 SDK shade(打进同一个 JAR)到自己的后端里。如果 SDK 依赖 Gson 或 lsp4j,就会和插件自己的同名依赖冲突,许可证审查范围也会变大。 -- **发布方式:主仓库打 `sdk-java-v<版本>` 标签,Release 附带 JAR 和 `.sha256`。** 工作流是 `.github/workflows/release-java-plugin-sdk.yml`,暂不发到 Maven 仓库。插件仓库在构建时按固定版本和 SHA-256 下载并校验,再 shade 进后端 JAR,运行时不下载任何东西。 +- **发布方式:打 `sdk-java-v<版本>` 标签,Release 附带 JAR 和 `.sha256`。** 0.1.0 是从主仓库发布的,已锁定它的插件不用改;之后的版本由 lithe-plugin-docs 的 `.github/workflows/release-java-sdk.yml` 发布。暂不发到 Maven 仓库。插件仓库在构建时按固定版本和 SHA-256 下载并校验,再 shade 进后端 JAR,运行时不下载任何东西。 - **处理器各自跑在守护线程池上,读循环只负责分帧和派发。** 取消不打断(interrupt)线程,只置位取消令牌,并结束该调用启动的进程。这是因为中断会让处理器里正在进行的文件写入半途失败。 - **进程按调用清理,用进程组,与 Node SDK 一致。** 常驻后端不会因为一次调用结束而退出,宿主按 `LITHE_PLUGIN_BACKEND_SCOPE` 的清理要等会话结束才发生,所以 SDK 必须在调用结束或取消时自己结束工具留下的进程。Java 没有进程组 API,macOS 和 Linux 上 SDK 通过系统 `perl`(`setpgrp(0, 0)` 后 `exec` 成工具,PID 不变)让每个工具成为自己进程组的组长:取消时先给整组发 SIGTERM,宽限期后发 SIGKILL;工具正常退出后立即对整组发 SIGKILL,结束它留下的后台进程。信号经 `/bin/kill -- -` 发出,因此 Java 8 和 Java 9+ 行为相同;只要组里还有成员,组 ID 就不会被复用,所以不会误伤其他并发调用。`PERL5OPT` 会让 perl 启动时加载模块,SDK 先从 perl 的环境里拿掉,再在 `exec` 前还给工具。启动失败时 launcher 以 127 退出并带固定前缀,SDK 仍抛 `IOException`,与 `ProcessBuilder` 一致。Windows 或没有 `perl` 时退回 `ProcessHandle.descendants()`(Java 9+,Windows 不会重挂父进程,工具退出后仍能找到子孙);Java 8 只能结束直接子进程。用 `setsid` 脱离进程组的进程,以及退回路径够不到的进程,仍由宿主按后端标记在会话结束时兜底。 - **取消监听器互相隔离。** 一个 `onCancel` 回调抛异常只记录到 stderr,不影响其余回调;调用收尾放在 `finally` 里,保证请求一定回复,宿主不会只等到超时。 @@ -106,28 +110,23 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 ## 后果 - 收益:插件作者只写处理器;取消、清理、错误映射和状态存储默认正确,Lithe 的统一管理规则不依赖作者自觉。 -- 收益:示例插件经过真实 Core 会话验证,契约改动若破坏 SDK,Rust Core lane 会直接失败。 +- 收益:示例插件经过真实 Core 会话验证。lithe-plugin-docs 每次提交都用契约副本锁定的那个主仓库 commit 跑端到端测试,每周再用主仓库 `preview` 分支的最新 Core 跑一次,Core 改动破坏 SDK 时会在这里失败。 - 代价:Node SDK 以复制源码方式分发,Java SDK 被 shade 进插件 JAR,插件里的副本都不会自动升级;修复安全问题时需要插件重新发布。 -- 代价:Rust Core lane 需要 Node、JDK 和 Maven;CI 用 Java 8 运行时跑 Java SDK 测试和示例后端,用 JDK 21 编译。 -- 代价:仓库新增一个顶层目录和一条 CI 测试,`sdk/*` 改动会触发 Rust Core lane。 +- 代价:SDK 移出主仓库后,主仓库的 PR 不再当场发现“Core 改动破坏 SDK”,最晚由 lithe-plugin-docs 的每周检查发现。改 Core 的插件后端协议时,应在合并前用 lithe-plugin-docs 的 `node scripts/sdk-e2e.mjs --source <本地主仓库> --ref <分支>` 手动跑一次。 +- 代价:lithe-plugin-docs 的端到端测试需要 Rust、Node、JDK 和 Maven,每次都要编译一遍 Core;CI 用 Java 8 运行时跑 Java SDK 测试和示例后端,用 JDK 21 编译。 - 代价:宿主还没有调用函数的界面入口,也没有宿主能力(`lithe/host/*`);Windows 没有插件后端宿主。SDK 目前对用户不可见,只给开发者和后续 PR 使用。 ## 验证 -- `node --test sdk/node/test/*.test.mjs`:协商、ID 规则(对照 `function-protocol.json`)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期和退出码、分帧、进程树取消、状态存储与只读包拒绝、类型声明与运行时导出一致。 -- `LITHE_PLUGIN_SDK_NODE="$(command -v node)" cargo test --manifest-path rust/Cargo.toml -p lithe-core --test plugin_sdk_node`:示例插件经 Core 校验、启动计划、版本检查、构建模型/依赖、函数结果/进度/失败/取消、重启后状态恢复,最后确认插件包文件清单不变。 +以下命令都在 lithe-plugin-docs 中运行: + +- `node --test sdk/node/test/*.test.mjs`:协商、ID 规则(对照 `contract/v1/fixtures/function-protocol.json`)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期和退出码、分帧、进程树取消、状态存储与只读包拒绝、类型声明与运行时导出一致。 - `mvn -B -f sdk/java/pom.xml verify [-Djvm=]`:Java SDK 的 JUnit 测试,覆盖与 Node 测试相同的协议场景,另含 JSON、分帧、进程树取消和状态存储。 -- `cargo test … --test plugin_sdk_java`(需要 `LITHE_PLUGIN_SDK_JAVA`、`LITHE_PLUGIN_SDK_JAVA_SAMPLE`、`LITHE_PLUGIN_SDK_NODE`):Java 示例按插件实际打包方式(`plugin.json` + shade 后的 JAR)装进临时包目录,跑同一套 Core 场景。 -- `./scripts/verify-rust-core-crates.sh`:CI 中运行以上全部;设置 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 时,Java 测试和示例后端在该运行时(CI 为 Java 8)上运行。 -- `bash scripts/test-classify-ci-changes.sh`:`sdk/*` 改动被分到 Rust Core lane。 +- `node scripts/sdk-e2e.mjs`:把 `sdk/e2e` 复制进锁定版本主仓库的 `rust/lithe-core/tests/`,运行 `plugin_sdk_node` 和 `plugin_sdk_java`。Node 示例经 Core 校验、启动计划、版本检查、构建模型/依赖、函数结果/进度/失败/取消、重启后状态恢复,最后确认插件包文件清单不变;Java 示例按插件实际打包方式(`plugin.json` + shade 后的 JAR)装进临时包目录,跑同一套场景。设置 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 时,Java 示例在该运行时(CI 为 Java 8)上运行。 +- `node scripts/sdk-e2e.mjs --ref preview`:同上,换成主仓库 `preview` 分支的最新 Core;CI 每周运行。 ## 适用范围 -- `sdk/node/` -- `sdk/java/` -- `rust/lithe-core/tests/plugin_sdk/`、`plugin_sdk_node.rs`、`plugin_sdk_java.rs` +- `https://github.com/Lithe-IDEA/lithe-plugin-docs` - `rust/lithe-core/src/plugins/declarative.rs`(运行时启动计划的版本探测参数) -- `.github/workflows/release-java-plugin-sdk.yml` -- `scripts/verify-rust-core-crates.sh` -- `scripts/classify-ci-changes.sh` - `shared/contracts/plugins/v1/README.md` diff --git a/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md new file mode 100644 index 0000000000..37783dfe71 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md @@ -0,0 +1,203 @@ +# Agent 笔记:插件契约在主仓库定义,完整副本、SDK 和开发者资料发布到 lithe-plugin-docs + +状态:已实现 + +## 先说结论 + +插件契约(`shared/contracts/plugins/v1/`,规定 `plugin.json` 每个字段的含义、错误码和标准答案用例)仍然在主仓库定义,由 Rust Core 执行。公开仓库 `Lithe-IDEA/lithe-plugin-docs` 的 `contract/v1/` 保存这个目录的**完整逐字节副本**,另外放开发指南、从 Lithe Store 搬过来的两篇教程、两个官方 SDK(源码、发版和端到端测试)和示例插件列表。插件开发者只看 lithe-plugin-docs 就能开发,写插件、发布插件都不需要克隆主仓库,也不需要给主仓库提 PR。 + +日常开发要记住四件事: + +1. 契约目录下任何文件合并后,都要到 lithe-plugin-docs 运行同步脚本。这条规则也写在根目录 `AGENTS.md`。 +2. 契约 README 里指向目录外的链接必须用绝对 URL,因为副本只包含这个目录。 +3. 契约 fixture(测试用的示例清单)按“演示什么能力”命名,不按真实插件命名。 +4. 主仓库不再保存 SDK。SDK 的修改和发版都在 lithe-plugin-docs;改 Core 的插件后端协议时,合并前要用它的 `scripts/sdk-e2e.mjs` 跑一次 SDK 端到端测试。 + +## 问题 + +主仓库里有很多插件 JSON,看起来像是各个插件仓库的配置副本。逐个盘点后,它们分成三类: + +1. **契约本身**:schema、README 和错误码。Rust Core 的测试用 `include_str!` 直接读取它们,Swift 测试也读取其中的 fixture,所以它们必须和执行它们的代码在同一个提交里修改。 +2. **契约 fixture**:文件名是 `maven-build`、`php-support`、`cargo-build` 这类真实插件名,ID 也是 `dev.lithe.fixture.maven-build`。实际上它们只是借 Cargo、Go、Gradle、Maven 当真实输入,用来演示某种能力,例如“从命令输出解码项目模型”。名字让人误以为主仓库在维护这些插件的配置。 +3. **确实属于插件仓库的副本**:例如 `rust/lithe-core/src/tests/fixtures/community_build_plugins/` 中 Maven、Gradle 插件的快照。这一类本次不处理,留作后续迁移。 + +除此之外,外部开发者需要的东西分散在主仓库和 Lithe Store: + +- 字段语义、错误码和标准用例只在主仓库。 +- schema 的 `$id` 是 `https://lithe.dev/...`,但这个域名不归我们所有,地址解析不到,编辑器没法用它。 +- 两个 SDK 的源码在主仓库,Node SDK 也没有正式发布,只能从主仓库复制 `src/`。 +- 教程在 Lithe Store,Store 还有一份已经过期的 schema 副本。没有一个“我要写插件,从哪里开始”的入口。 + +## 决策 + +### 契约在主仓库定义 + +契约和执行它的代码(Rust Core 的校验和标准答案测试)必须在同一个 PR 里修改。如果把契约的源头移到别的仓库,改一个字段就要两个 PR,中间还会出现“契约写的是 A、Core 做的是 B”的时间窗口。外部开发者只需要**读**契约、不需要**改**契约,读的需求由完整副本满足。 + +### lithe-plugin-docs 是开发者的唯一入口 + +- `README.md`:中文开发指南,讲插件结构、API 等级怎么选、编辑器校验、后端 SDK 和示例插件。 +- `guides/`:从 Lithe Store 搬来的《LSP 插件完整生命周期》和《构建、运行与测试接入》。契约链接改为本仓库的 `contract/v1/`,发布相关链接指向 Store。 +- `contract/v1/`:主仓库 `shared/contracts/plugins/v1/` 整个目录的逐字节副本,包括 README、两份 schema 和全部 fixture。 +- `contract.lock.json`:记录来源仓库、来源目录、commit 和每个文件的 SHA-256,格式与 Cargo 插件的 `sdk.lock.json` 相同。 +- `sdk/node`、`sdk/java`:两个官方 SDK 和各自的示例插件;`sdk/e2e`:两个示例共用的端到端场景(Rust 测试文件)。 +- `scripts/contract.mjs` 提供四个命令: + - `sync --revision `:按来源目录在该 commit 的 git tree 整体替换副本,新增、删除、修改的文件都跟上,然后重写锁文件。 + - `verify`:离线检查锁文件、哈希、schema `$id`、Markdown 相对链接和未锁定文件。 + - `verify --upstream`:用 GitHub API 取锁定 commit 的 tree,按 git blob ID 比较整个目录。 + - `drift`:检查主仓库 `preview` 分支上的契约是否已经变化。不用默认分支:主仓库的默认分支是稳定版 `main`,只在发版时追上 `preview`,拿它比较会让刚同步过的副本每周都报差异。 + + docs 仓库的 CI 每次提交运行前两项检查和脚本测试,每周运行一次 `drift`。 +- `scripts/sdk-e2e.mjs`:SDK 端到端测试,见下文“SDK 整体移到 lithe-plugin-docs”。 + +只复制 schema 不够:字段之间的约束、错误码和预期结果都写在契约 README 和 fixture 里,只有 schema 的话,开发者仍然要回主仓库查。 + +### Lithe Store 只管收录和上架 + +Store 保留 `publishing.md`、`versioning-and-updates.md`、`maintainers.md` 和 `CONTRIBUTING.md`,讲怎么提交收录、怎么追踪版本。两篇教程搬到 lithe-plugin-docs,Store 的 `schema/plugin-index.schema.json` 副本删除。 + +Store 的 `validate-index.mjs` 仍然实现索引规则。它的 CI 原来比较本地 schema 副本和主仓库 preview,现在改为检查 lithe-plugin-docs 上 `plugin-index.schema.json` 的 SHA-256 是否等于工作流里记录的值。不相等说明索引契约变了,Store 维护者更新校验脚本后再更新记录的哈希。 + +### Schema 地址固定为 docs 仓库的 raw 地址 + +两份 schema 的 `$id` 是它在 docs 仓库里的地址: +`https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/<文件名>`。 + +两边都对这个地址做严格检查: + +- 主仓库的 `scripts/verify-shared-contracts.sh` 用常量 `published_plugin_schema_base` 要求 `$id` 完全相等。 +- docs 仓库的 `contract.mjs` 要求每份 schema 的 `$id` 等于它自己的发布地址。 + +因此,如果主仓库改了地址而 docs 仓库没跟上,或者 docs 仓库里的文件不是从主仓库同步来的,CI 都会失败。 + +### 契约 README 的外链写绝对地址 + +副本只包含契约目录,`../` 开头的相对链接在 docs 仓库里会失效。所以契约 README 指向 `rust-core-api.md`、Agent Notes 的链接都写成 `https://github.com/1lck/Lithe-IDEA/blob/preview/...`,指向 SDK 的链接写成 lithe-plugin-docs 的地址。 + +两边都检查这条规则: + +- 主仓库的 `verify-shared-contracts.sh` 拒绝离开目录或指向不存在文件的相对链接。 +- docs 仓库的 `contract.mjs` 在同步和校验时也拒绝这类链接。 + +### `plugin.json` 里不能写 `$schema` + +Core 解析清单时拒绝未知字段(serde `deny_unknown_fields`),schema 本身也是 `additionalProperties: false`。如果开发者按习惯加一行 `"$schema"`,插件会因为 `plugin.manifest.invalid` 装不上。所以指南要求在编辑器里按文件名关联 schema,例如 VS Code 的 `json.schemas`。是否在以后的契约版本里允许 `$schema` 另行决定:那需要提高能力版本,老版本 Lithe 会拒绝新格式。 + +### fixture 按能力命名 + +`shared/contracts/plugins/v1/fixtures/` 中的 fixture 改为按演示的能力命名,ID 同步改成 `dev.lithe.fixture.<新名字>`: + +| 原名 | 新名 | 演示什么 | +| --- | --- | --- | +| `php-support` | `language-packaged-server` | 随包附带语言服务器 | +| `go-support` | `language-system-command` | 使用用户安装的命令作为语言服务器 | +| `java-support` | `language-host-integration` | 交给宿主集成的语言服务器 | +| `cargo-build` | `build-static-file` | 从项目文件读取构建模型 | +| `go-build` | `build-command-stdout` | 解码命令的标准输出 | +| `gradle-build` | `build-command-output-file` | 解码命令写出的文件 | +| `maven-build` | `build-host-integration` | 把构建模型交给宿主集成 | +| `gradle-provider` | `build-provider` | 构建后端 | +| `mix-provider` | `build-provider-language-server` | 与语言服务器共用进程的构建后端 | + +fixture 的内容仍然用 Cargo、Go、Gradle、Maven 作为真实输入,契约 README 写明“它们不是这些插件”。macOS 测试中同 ID 的展示用副本(`macos/Tests/LitheTests/Fixtures/BuildSystems/`)一起改名。 + +`shared/fixtures/plugins/language-support-v1.json` 保留旧 ID `dev.lithe.fixture.go-support`。它是已退役的 schema 1 格式,只被 `rust/lithe-core/src/tests/plugins.rs` 的旧格式测试读取,与声明式契约无关。 + +### SDK 整体移到 lithe-plugin-docs + +SDK 是给插件开发者用的库,不是契约。开发者要读它的 README、看示例、提问题、下载发布包,这些都应该在 lithe-plugin-docs 完成,所以 `sdk/`、SDK 端到端测试和两个发版工作流都从主仓库移走,主仓库只保留契约。 + +- **发版**:lithe-plugin-docs 打 `sdk-node-v<版本>` 或 `sdk-java-v<版本>` 标签,`release-node-sdk.yml`、`release-java-sdk.yml` 发布包和 `.sha256`。具体规则见 [官方插件后端 SDK](2026-10-20-plugin-backend-sdk.md)。Java SDK 0.1.0 是迁移前从主仓库发布的,那个 Release 保留,Maven 插件锁定的下载地址不变。 +- **单元测试**:SDK 自己的测试读 `contract/v1/fixtures/function-protocol.json`,也就是契约副本,不再读主仓库。 +- **端到端测试**:场景仍是 Rust 测试,要链接 Rust Core。`sdk-e2e.mjs` 把锁文件记录的主仓库 commit 稀疏检出到 `.artifacts/lithe`(只取 `rust/` 和 `shared/`),把 `sdk/e2e` 复制进它的 `rust/lithe-core/tests/`,再用 `cargo test --locked` 运行。`LITHE_PLUGIN_SDK_ROOT` 告诉测试示例插件在哪里。这和 Cargo 插件测 Core 的方式相同。 +- **两个版本的 Core**:每次提交测契约副本锁定的那个 commit,保证 SDK、契约副本和 Core 是同一个版本;每周再用 `--ref preview` 测主仓库 `preview` 分支的最新 Core,提前发现 Core 的改动破坏了 SDK。 + +代价是主仓库的 PR 不再当场发现“Core 改动破坏 SDK”。改插件后端协议(`rust/lithe-core/src/lsp/interface/plugin_function.rs`、`litheBuild` 相关代码)时,合并前要在 lithe-plugin-docs 用 `--source <本地主仓库> --ref <分支>` 手动跑一次。 + +### 正确做法 + +- 修改契约:在同一个 PR 里改 `shared/contracts/plugins/v1/`、Core 和测试。合并后,在 lithe-plugin-docs 运行 `node scripts/contract.mjs sync --revision <合并后的 preview commit>`,然后提交。 +- 改了 `plugin-index.schema.json`:同步 docs 仓库之后,Lithe Store 的 CI 会失败,按 Store 的 `docs/maintainers.md` 更新索引校验和记录的哈希。 +- 在契约 README 里引用目录外的文件:写 `https://github.com/1lck/Lithe-IDEA/blob/preview/<路径>`。 +- 新增 fixture:按演示的能力命名,例如 `build-command-stdout.plugin.json`,ID 使用 `dev.lithe.fixture.` 前缀。 +- 新增面向插件开发者的说明:写进 lithe-plugin-docs 的 README 或 `guides/`。 +- 修改或发布 SDK:在 lithe-plugin-docs 提 PR、打标签。 +- 修改插件后端协议:合并前在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本地主仓库> --ref <分支>`。 + +### 不要这样做 + +- 在 lithe-plugin-docs 里直接编辑 `contract/` 下的文件,或者让主仓库 schema 的 `$id` 指向别的地址。两边的 CI 都会失败。 +- 在契约 README 里写 `../` 开头的相对链接。 +- 用真实插件名命名 fixture,例如 `maven-build.plugin.json`。读者会以为主仓库在维护那个插件的配置。 +- 在指南或示例里让开发者往 `plugin.json` 写 `$schema`。 +- 把插件开发教程写回 Lithe Store 或主仓库的 `Plugins/`。 +- 在主仓库重新加 `sdk/` 目录,或者让主仓库 CI 检出 lithe-plugin-docs 跑 SDK 测试。依赖方向是 docs 仓库依赖主仓库,反过来会让主仓库的结果取决于另一个仓库的 `main`。 + +## 考虑过的备选方案 + +### 把契约的源头移出主仓库 + +开发者只看一个仓库,这个目标现在由完整副本实现。真正把源头移走有两种做法,都没有采用: + +- **git submodule(子模块)**:主仓库测试读取子模块里的文件。每次改契约都要先在契约仓库合并,再在主仓库更新子模块指针;所有克隆和 CI 都要多一步初始化子模块。 +- **测试时按锁文件下载契约**:主仓库只保存锁文件,构建前下载。Rust 测试用 `include_str!` 在编译期读取 fixture,离线构建和每个 worktree 都要先准备下载物,还要纳入 worktree 资源复用清单。 + +两种做法都让“改契约、改 Core、改测试”变成跨仓库的多步操作,复杂度高于每次合并后同步一次副本。 + +### 只发布两份 schema 副本 + +这是最初的方案,docs 仓库只放 schema。没有采用,因为字段语义、错误码和标准用例都在契约 README 和 fixture 里,开发者遇到 schema 表达不了的规则时仍然要回主仓库。 + +### 把 schema 放到主仓库的 GitHub Pages 或主仓库 raw 地址 + +这样不用多建一个仓库。但 Pages 目前用来发布 Agent Notes 看板,自定义域名 `notes.lithe.top` 还没有 HTTPS 证书。如果用主仓库 raw 地址,开发者的入口仍然是主仓库,而且主仓库调整目录结构就会改变公开地址。 + +### 教程和契约副本都放在 Lithe Store + +Store 原来已经有教程和一份 `schema/plugin-index.schema.json`。没有采用,是因为 Store 负责收录和上架,它的 schema 副本已经和主仓库不一致(`$id` 还是 `lithe.dev`)。把“怎么写插件”和“怎么上架”分开,Store 的收录规则可以保持单一职责。 + +### SDK 留在主仓库,只单独发版 + +这是本次最初的做法:端到端测试留在 Rust Core lane,改 Core 时当场发现 SDK 被破坏。没有采用,因为开发者仍然要到主仓库读 SDK 的 README、看示例、提 issue,改 SDK 也要给主仓库提 PR,和“开发者只看 lithe-plugin-docs”的目标冲突。端到端测试改成按锁文件检出 Core 以后,跨仓库固定 Core 版本的成本可以接受。 + +### 主仓库 CI 检出 lithe-plugin-docs 跑 SDK 端到端测试 + +这样改 Core 时仍能当场发现问题。没有采用:主仓库的测试结果会取决于另一个仓库 `main` 分支的状态,docs 仓库合并一个坏提交就会让主仓库所有 PR 失败;同时形成两个仓库互相依赖。 + +### 把 Node SDK 发布到 npm + +npm 是 Node 开发者最熟悉的方式。但插件包安装后没有 `npm install` 步骤,开发者最终还是要把 SDK 文件复制进插件包。发布到 npm 还要管理组织和令牌,目前没有必要。 + +## 后果 + +- 收益:外部开发者只需要 lithe-plugin-docs;schema 地址可以真正解析,编辑器能直接提示;fixture 名字不再误导;两个 SDK 的源码、发版和问题都在同一个仓库,Node SDK 也有带哈希的正式下载。 +- 代价:契约变更后要多做一次同步。同步之前,docs 仓库的副本会落后于主仓库,每周的 `drift` 检查会失败提醒。 +- 代价:Core 改动破坏 SDK 时,主仓库 PR 不会失败,最晚由 docs 仓库的每周检查发现;docs 仓库的端到端测试每次都要编译一遍 Core。 +- 代价:契约 README 不能用相对链接引用主仓库的其他文件,这些绝对链接指向 `preview`,会随主仓库调整目录而失效。 +- 代价:schema `$id` 绑定了 docs 仓库 `main` 分支的 raw 地址。仓库改名或换默认分支都会改变公开地址,必须同时改两边的检查。 +- 后续工作: + - `community_build_plugins` 中 Maven、Gradle 快照迁回插件仓库。 + - Lua、Gradle、Maven 插件 README 里指向 Store 教程和主仓库契约的链接,改为指向 lithe-plugin-docs。 + - Windows PhpSupport 插件迁移。 + - 其他共享 schema 仍然使用 `lithe.dev` 作为 `$id`。 +- 重新评估的条件:如果契约开始需要外部贡献者直接修改,或者 Rust Core 拆成独立仓库,就重新考虑契约放在哪里。 + +## 验证 + +- `./scripts/verify-shared-contracts.sh`:两份 schema 的 `$id` 必须等于 docs 仓库的发布地址;契约 README 的相对链接不能离开目录;fixture ID 必须以 `dev.lithe.fixture.` 开头。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib -- declarative_plugins build_systems marketplace_plugins build_runtime_versions`:改名后的 fixture 仍然得到相同的标准答案。 +- `bash scripts/test-classify-ci-changes.sh`:主仓库的 CI 分类不再有 `sdk/` 路由。 +- 在 lithe-plugin-docs 运行 `node --test scripts/*.test.mjs` 和 `node scripts/contract.mjs verify --upstream`:同步脚本的测试通过,副本与锁定 commit 的契约目录完全一致。 +- 在 lithe-plugin-docs 运行 `node --test sdk/node/test/*.test.mjs`、`mvn -B -f sdk/java/pom.xml verify` 和 `node scripts/sdk-e2e.mjs`:两个 SDK 的单元测试读契约副本通过,两个示例后端在锁定版本的 Core 上跑完端到端场景。 + +## 适用范围 + +- `AGENTS.md` +- `shared/contracts/plugins/v1/` +- `scripts/verify-shared-contracts.sh` +- `scripts/verify-rust-core-crates.sh` +- `scripts/classify-ci-changes.sh` +- `.github/workflows/ci-macos.yml` +- `Plugins/README.md` +- `https://github.com/Lithe-IDEA/lithe-plugin-docs` +- `macos/Tests/LitheTests/Fixtures/BuildSystems/` diff --git a/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md b/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md index 826eb303a2..ef72a2c797 100644 --- a/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md +++ b/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md @@ -164,7 +164,7 @@ BSP 本身也是 JSON-RPC 上的进程外构建协议,生命周期与 LSP 几 1. **P0 契约骨架(Core 已完成)**:API 3 的 `buildSystems`、13 个 `build.*` 命令、Cargo/Go/Gradle/Maven 四个 fixture 和 `build-plans.json` 标准答案。 2. **P1 通用工具窗口与运行配置**:两端实现第 2 节的宿主能力和 Build 工具窗口;`runConfig.generate` 接入构建模型,先迁移 Cargo;把 npm、just、Make、Composer 等现有探测器改写成内置声明;定下 `buildSystems.test` 与 `executionProviders.test` 在同一工程里谁优先。 -3. **P2 进程型 Provider 与 Gradle**:`provider` 声明、启动计划和回复解码的 Core 部分已完成(`gradle-provider`、`mix-provider` fixture 和标准答案)。macOS 宿主部分(协商、发送 `lithe/build/*`、进程生命周期)已完成,剩下 Windows 宿主。Gradle 先用插件包内的 init script 输出 `litheBuildModel`(fixture 已验证这条路)关闭 Issue #10,再评估改用 Tooling API 的 Provider 以获得结构化进度和测试事件;Go、Cargo 的依赖树走模型命令档。 +3. **P2 进程型 Provider 与 Gradle**:`provider` 声明、启动计划和回复解码的 Core 部分已完成(`build-provider`、`build-provider-language-server` fixture 和标准答案)。macOS 宿主部分(协商、发送 `lithe/build/*`、进程生命周期)已完成,剩下 Windows 宿主。Gradle 先用插件包内的 init script 输出 `litheBuildModel`(fixture 已验证这条路)关闭 Issue #10,再评估改用 Tooling API 的 Provider 以获得结构化进度和测试事件;Go、Cargo 的依赖树走模型命令档。 4. **P3 BSP**:有实际需求(Scala、Bazel、SwiftPM)时,在同一传输层上加入 `bsp/2` 协议。 5. **P4 Maven 收编**:官方插件 `dev.lithe.maven` 先以 `hostIntegration: maven` 进入同一领域模型,由通用工具窗口渲染,Windows 随后用同一组 fixture 切换。最后改为插件自带的 Provider,从 Core 删除 `maven` 集成。逐项对照见下一节。 @@ -179,7 +179,7 @@ BSP 本身也是 JSON-RPC 上的进程外构建协议,生命周期与 LSP 几 除特别标注外,各项都在 P4 迁移。 -Core 侧已经具备的部分:fixture `maven-build.plugin.json`(插件 ID 为 `dev.lithe.fixture.maven-build`)声明了 Wrapper、`MAVEN_HOME` 和 `PATH` 三类候选,Profile、`settings.xml`、本地仓库、离线和跳过测试五个选项,`clean`、`compile`、`package`、`install` 和自由输入的 goal,统一后的单测模板,以及诊断正则;`maven` 集成负责 reactor 模型、依赖树和 Surefire 结果。表里“迁移做法”剩下的主要是宿主工作、正式的 `dev.lithe.maven` 清单,以及删除旧命令。 +Core 侧已经具备的部分:fixture `build-host-integration.plugin.json`(插件 ID 为 `dev.lithe.fixture.build-host-integration`)声明了 Wrapper、`MAVEN_HOME` 和 `PATH` 三类候选,Profile、`settings.xml`、本地仓库、离线和跳过测试五个选项,`clean`、`compile`、`package`、`install` 和自由输入的 goal,统一后的单测模板,以及诊断正则;`maven` 集成负责 reactor 模型、依赖树和 Surefire 结果。表里“迁移做法”剩下的主要是宿主工作、正式的 `dev.lithe.maven` 清单,以及删除旧命令。 #### 工程模型 @@ -317,7 +317,7 @@ Core 契约部分的验收已在已实现的 Note 里完成。剩下的工作按 - 安装一个第三方 Gradle 构建插件后,它注入的每个运行任务各自生成一条运行配置,名字包含 project 路径和任务名,满足 Issue #10 的验收条件,Core 和宿主都不改代码。 - 在未信任的工作区打开项目时,没有任何 `command` 档进程被启动;Wrapper 只在用户操作后运行。 - 只有 `buildSystems: [1]` 的宿主可安装模板构建插件,但市场、清单校验和安装审查必须拒绝自有与共享 Provider;宿主真正实现 Provider 后才加入 `buildProviders: [1]`。已有 `declarative_plugins` 和 `marketplace_plugins` 回归覆盖这一边界,后续宿主实现要保留这些断言。 -- `gradle-provider` 这类自有 Provider 只在用户操作或工作区信任后启动,并登记进进程账本;`mix-provider` 这类共享写法不启动第二个进程,在工作区信任前不发送 `lithe/build/*`。 +- `build-provider` 这类自有 Provider 只在用户操作或工作区信任后启动,并登记进进程账本;`build-provider-language-server` 这类共享写法不启动第二个进程,在工作区信任前不发送 `lithe/build/*`。 - 任务通过现有执行链路运行,具备取消、期限和输出上限;禁用插件会停止它登记的守护进程,用户在终端启动的同一工具不受影响。 - `junitXml` 和 `lineMatcher` 解码出的诊断与测试结果能跳转到编辑器位置。 - 迁移过程中 Maven 工具窗口的生命周期、Profile、依赖树和执行目标行为不变;`build.*` 与 `maven.*` 并行期间,两者对同一组 Maven fixture 的结果一致。 diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index 4fe60eca0f..0774017a13 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -80,7 +80,6 @@ individual upstream commands were called. | `Plugins/win/` | Windows-owned plugin packages | | `frontend/editor/` | Shared Monaco presentation, tokenization, and editor model lifecycle; no platform APIs | | `shared/` | Cross-platform contracts and fixtures, not compiled implementation | -| `sdk/` | Official plugin backend SDKs and their samples; they implement `shared/contracts/plugins/` and never define protocol | | `infra/` | Repository-level development and validation infrastructure | | `third_party/` | Upstream code; leave unchanged unless the task explicitly targets it | diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 19c22e60d1..717f51c1e4 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -385,24 +385,7 @@ jobs: with: cargo: "true" - # The Java plugin SDK targets Java 8: its tests and sample backend run on - # a Java 8 runtime, while Maven builds with the newer JDK set up last. - - name: Set up Java 8 runtime for the plugin SDK - id: java8 - uses: actions/setup-java@v5 - with: - distribution: zulu - java-version: '8' - - - name: Set up Java 21 for Maven - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - name: Run Rust Core tests - env: - LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java run: ./scripts/verify-rust-core-crates.sh - name: Upload Rust test reports @@ -415,7 +398,6 @@ jobs: .artifacts/test-stability/agent-host-rust.* .artifacts/test-stability/ide-host-rust.* .artifacts/test-stability/rust-comment-checker.xml - .artifacts/test-stability/TEST-io.github.litheidea.plugin.sdk.*.xml if-no-files-found: ignore retention-days: 14 diff --git a/.github/workflows/release-java-plugin-sdk.yml b/.github/workflows/release-java-plugin-sdk.yml deleted file mode 100644 index 3bc5b99217..0000000000 --- a/.github/workflows/release-java-plugin-sdk.yml +++ /dev/null @@ -1,81 +0,0 @@ -# Releases the official Java plugin SDK (`sdk/java`) through its own tag series -# (`sdk-java-v`), independent of Lithe app releases. The release holds -# `lithe-plugin-sdk-java-.jar` and its `.sha256`; plugin repositories -# pin both and shade the JAR into their backend at build time. -name: Release Java Plugin SDK - -on: - push: - tags: - - "sdk-java-v*" - -permissions: - contents: read - -concurrency: - group: release-java-plugin-sdk-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - name: Release ${{ github.ref_name }} - if: github.repository == '1lck/Lithe-IDEA' - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: write - - steps: - - name: Check out source - uses: actions/checkout@v7 - - # The SDK supports Java 8: the tests run on a Java 8 runtime, while - # Maven builds with the newer JDK set up last. - - name: Set up Java 8 runtime - id: java8 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '8' - - - name: Set up Java 21 for Maven - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - - name: Require the tag to match the SDK version - id: version - env: - TAG: ${{ github.ref_name }} - run: | - version=$(mvn -B -ntp -q -f sdk/java/plugin-sdk/pom.xml help:evaluate -Dexpression=project.version -DforceStdout) - test "$TAG" = "sdk-java-v$version" || { echo "Tag $TAG does not match sdk/java version $version"; exit 1; } - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Build and test on Java 8 - env: - JAVA8: ${{ steps.java8.outputs.path }}/bin/java - run: mvn -B -ntp -f sdk/java/pom.xml verify -Djvm="$JAVA8" - - - name: Collect the release assets - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - mkdir -p dist - asset="lithe-plugin-sdk-java-$VERSION.jar" - cp "sdk/java/plugin-sdk/target/$asset" "dist/$asset" - (cd dist && sha256sum "$asset" | cut -d' ' -f1 > "$asset.sha256") - - - name: Publish the SDK release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - VERSION: ${{ steps.version.outputs.version }} - run: | - asset="lithe-plugin-sdk-java-$VERSION.jar" - # --latest=false keeps the app's own release as the repository's latest. - gh release create "$TAG" "dist/$asset" "dist/$asset.sha256" \ - --title "Java plugin SDK $VERSION" \ - --latest=false \ - --notes "Official Java plugin SDK $VERSION for Lithe plugin backends. Requires Java 8 or newer and has no runtime dependencies. Pin the JAR by its SHA-256 (\`$asset.sha256\`) and shade it into the backend JAR; see sdk/java/README.md." diff --git a/.gitignore b/.gitignore index 4c4467b884..0ad31b4d7e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ DerivedData/ dist/ .artifacts/ shared/fixtures/projects/**/target/ -sdk/java/**/target/ *.xcuserstate *.xcuserdata/ design-qa-artifacts/ diff --git a/AGENTS.md b/AGENTS.md index a4a30a827d..820fc0d576 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,27 @@ [LSP 插件构建与语言服务器资源归属](.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md)。 所有 LSP 插件都必须在构建阶段固定来源并校验语言服务器,把完整资源放入插件包后签名;运行时只能从已安装插件发现入口,不能把下载物、解压物或插件状态写入 app bundle、安装目录或跨工作树共享缓存。 +## 插件契约的两仓库同步 + +插件契约在本仓库的 `shared/contracts/plugins/v1/` 定义,和执行它的 Rust Core 一起修改。 +公开仓库 [Lithe-IDEA/lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) +的 `contract/v1/` 保存这个目录的逐字节副本,连同开发指南、教程和两个官方插件后端 SDK(Node、Java)的 +源码、发版和端到端测试。插件开发者只看 lithe-plugin-docs 就能开发,不需要克隆本仓库,所以两个仓库的契约 +必须保持一致: + +- 修改 `shared/contracts/plugins/v1/` 下任何文件(README、schema、fixture)的 PR 合并后,在 + lithe-plugin-docs 运行 `node scripts/contract.mjs sync --revision <合并后的 preview commit>` + 并提交。不要在 lithe-plugin-docs 里直接改副本。 +- 契约 README 里指向该目录以外的链接必须写成绝对 URL;`scripts/verify-shared-contracts.sh` + 会拒绝离开该目录的相对链接。 +- 面向插件开发者的新说明写进 lithe-plugin-docs,不写进本仓库或 Lithe Store。 +- 本仓库不保存 SDK。修改插件后端协议(例如 `rust/lithe-core/src/lsp/interface/plugin_function.rs`)时, + 合并前在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本仓库路径> --ref <分支>`,确认两个 + SDK 的示例后端仍能跑通。 + +决策原因、同步工具和被否方案见 +[插件契约、SDK 与开发者资料的发布](.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md)。 + ## 跨平台功能同步 跨平台功能矩阵的字段、状态语义、更新流程和 CI 例外规则以 diff --git a/Plugins/README.md b/Plugins/README.md index 2207964e77..31f2329fe6 100644 --- a/Plugins/README.md +++ b/Plugins/README.md @@ -8,3 +8,9 @@ Plugin packages are separated by host platform: Keep manifests, implementation source, platform resources, and focused tests inside the owning platform tree. Cross-platform plugin contracts and fixtures belong under the repository-level `shared/` directory. + +Community plugins live in their own repositories and never need a change here. +Their authors start at +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs), which has +the developer guide, tutorials, a pinned copy of the whole plugin contract, and +the official plugin backend SDKs. diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 6d96c37306..0f69c20fe2 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -65,6 +65,10 @@ key in a protected file or environment variable and never commit it. ## Local development packages +This section is for official plugins built in this repository. Community plugin +authors follow [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) +instead. + Plugin authors can test without the publisher key. Build an unsigned development package with `--development-unsigned`, then install it from disk in a debug build of Lithe: diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 8ac41ae7f3..693bd69e83 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -367,6 +367,9 @@ Cargo 社区插件在 [独立仓库](https://github.com/Lithe-IDEA/lithe-plugin- 不跨 worktree 共享,也不进入发布 ZIP。主仓库保留通用插件契约回归,不下载或组装 Cargo 插件。 插件仓库每次打包校验 `sdk.lock.json` 固定的 SDK 源码提交与文件 SHA-256, SDK 复制只发生在打包阶段,安装目录保持只读。 +官方插件 SDK 已移到 [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs): +SDK 的 Maven 构建输出和端到端测试使用的 Lithe 稀疏检出(`.artifacts/lithe`)都属于该仓库, +主仓库不再构建 SDK,也不把这些目录登记为可复用资源。 `Lithe-Dev` 的运行时用户状态不属于可复用构建缓存。macOS 用 `app.lithe.desktop.dev` 隔离偏好和钥匙串,并在系统 Application Support、Caches、 diff --git a/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift b/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift index ef9744c7b3..29753befb8 100644 --- a/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift +++ b/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift @@ -16,7 +16,7 @@ import Testing struct BuildSystemToolWindowTests { @Test func mavenPresentationFieldsDecodeThroughCore() throws { - let descriptor = try Self.descriptor("maven-build") + let descriptor = try Self.descriptor("build-host-integration") let maven = try #require(descriptor.buildSystems.first) #expect(maven.icon?.host == "tool-window") @@ -31,7 +31,7 @@ struct BuildSystemToolWindowTests { @Test func gradleTemplateCategoryIsTheDefaultOfModelTasks() throws { - let gradle = try #require(try Self.descriptor("gradle-build").buildSystems.first) + let gradle = try #require(try Self.descriptor("build-command-output-file").buildSystems.first) #expect(gradle.option("offline")?.presentation == .toolbar) #expect(gradle.tasks.first { $0.id == "gradle-task" }?.category == ["Tasks", "other"]) @@ -56,7 +56,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func everyBuildSystemDerivesItsOwnWindowAndToggleCommand() throws { - let descriptor = try Self.descriptor("gradle-build") + let descriptor = try Self.descriptor("build-command-output-file") let module = try #require(descriptor.pluginManifest.modules.first { $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) }) @@ -85,7 +85,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func enabledBuildSystemWindowShowsInAWorkspaceWithoutAnInstance() throws { - let descriptor = try Self.descriptor("gradle-build") + let descriptor = try Self.descriptor("build-command-output-file") let module = try #require(descriptor.pluginManifest.modules.first { $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) }) @@ -150,7 +150,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func restoringBuildWindowActivatesItsOwnerAndPaletteTracksPendingState() async throws { - let descriptor = try Self.descriptor("gradle-build") + let descriptor = try Self.descriptor("build-command-output-file") let declaration = try #require(descriptor.pluginManifest.modules.first { $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) }) @@ -182,8 +182,8 @@ struct BuildSystemToolWindowTests { @Test @MainActor func independentBuildWindowsRestoreDockAndVisibilityWithoutLegacyMavenEntries() throws { - let first = try Self.descriptor("maven-build") - let second = try Self.descriptor("gradle-build") + let first = try Self.descriptor("build-host-integration") + let second = try Self.descriptor("build-command-output-file") let declarations = [first, second].flatMap { $0.pluginManifest.modules }.filter { !$0.contributions.isEmpty } let owners = Dictionary(uniqueKeysWithValues: declarations.map { ($0.manifest.id, $0.contributions) }) let windows = declarations.flatMap(\.contributions).filter { $0.kind == .toolWindow } @@ -219,7 +219,7 @@ struct BuildSystemToolWindowTests { let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-build-icons-" + UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - var manifest = try #require(JSONSerialization.jsonObject(with: Self.descriptor("maven-build").normalizedManifest) as? [String: Any]) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.descriptor("build-host-integration").normalizedManifest) as? [String: Any]) var builds = try #require(manifest["buildSystems"] as? [[String: Any]]) let icon: [String: Any] = ["path": "light.svg", "darkPath": "dark.svg", "mode": "original"] builds[0]["icon"] = icon @@ -250,7 +250,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func optionRootSelectionPreservesEachInstancesChoicesAndEffectiveValue() throws { - let descriptor = try Self.descriptor("maven-build") + let descriptor = try Self.descriptor("build-host-integration") let declaration = try #require(descriptor.buildSystems.first) let source = BuildSystemPluginSource( pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, diff --git a/macos/Tests/LitheTests/Fixtures/BuildSystems/gradle-build.plugin.json b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-command-output-file.plugin.json similarity index 98% rename from macos/Tests/LitheTests/Fixtures/BuildSystems/gradle-build.plugin.json rename to macos/Tests/LitheTests/Fixtures/BuildSystems/build-command-output-file.plugin.json index eb3c1890a3..7b071b95e0 100644 --- a/macos/Tests/LitheTests/Fixtures/BuildSystems/gradle-build.plugin.json +++ b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-command-output-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-build", - "displayName": "Gradle Build Fixture", + "id": "dev.lithe.fixture.build-command-output-file", + "displayName": "Command Output File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { diff --git a/macos/Tests/LitheTests/Fixtures/BuildSystems/maven-build.plugin.json b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-host-integration.plugin.json similarity index 98% rename from macos/Tests/LitheTests/Fixtures/BuildSystems/maven-build.plugin.json rename to macos/Tests/LitheTests/Fixtures/BuildSystems/build-host-integration.plugin.json index b14826fa62..be8d482411 100644 --- a/macos/Tests/LitheTests/Fixtures/BuildSystems/maven-build.plugin.json +++ b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.maven-build", - "displayName": "Maven Build Fixture", + "id": "dev.lithe.fixture.build-host-integration", + "displayName": "Host Integration Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { diff --git a/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift b/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift index 59a57ad0a9..49d6cbd968 100644 --- a/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift +++ b/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift @@ -152,7 +152,7 @@ struct LanguageExtensionProcessLifecycleTests { defer { try? FileManager.default.removeItem(at: root) } let operations = DeclarativePluginTestSupport.operations let descriptor = try operations.validate(manifest: Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/java-support.plugin.json"))) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json"))) let packageURL = root.appendingPathComponent("package", isDirectory: true) let installed = InstalledPluginPackage( descriptor: descriptor, diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 3ce46fa91f..720c256beb 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -325,7 +325,7 @@ private final class ProgressReports { /// setting in its arguments. @MainActor private struct ProviderFixture { - static let pluginID = "dev.lithe.fixture.gradle-provider" + static let pluginID = "dev.lithe.fixture.build-provider" static let executablePath = "provider/bin/provider" let root: URL @@ -348,7 +348,7 @@ private struct ProviderFixture { try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) let fixtureURL = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json") + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider.plugin.json") var object = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] ) diff --git a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift index a65ed17050..d2ec248b62 100644 --- a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift +++ b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift @@ -146,7 +146,7 @@ struct PluginBuildOptionPersistenceTests { try Data("[package]\nname = \"\(root)\"\nversion = \"0.1.0\"\n[features]\n\(feature) = []\n".utf8) .write(to: directory.appendingPathComponent("Cargo.toml")) } - let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json") + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json") var manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: fixture)) as? [String: Any]) var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) var options = try #require(systems[0]["options"] as? [[String: Any]]) diff --git a/macos/Tests/LitheTests/PluginDiskInstallationTests.swift b/macos/Tests/LitheTests/PluginDiskInstallationTests.swift index 6177a7ba5c..caee8ddc2c 100644 --- a/macos/Tests/LitheTests/PluginDiskInstallationTests.swift +++ b/macos/Tests/LitheTests/PluginDiskInstallationTests.swift @@ -73,7 +73,7 @@ struct PluginDiskInstallationTests { signatureRequirement: PluginSignatureRequirement = .unsigned ) throws -> URL { let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/php-support.plugin.json")) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json")) var manifest = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) manifest["id"] = id.rawValue manifest["displayName"] = "Disk Fixture" diff --git a/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift b/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift index 3e24a3c993..72996b9add 100644 --- a/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift +++ b/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift @@ -402,7 +402,7 @@ struct PluginLanguageServerLaunchResolverTests { private func makeFixture( root: URL, - manifest: String = "java-support.plugin.json", + manifest: String = "language-host-integration.plugin.json", languageID: String = "java", settings: any PluginSettingsStoring = InMemoryPluginSettings() ) throws -> Fixture { @@ -458,7 +458,7 @@ struct PluginLanguageServerLaunchResolverTests { } private func javaFixtureManifest() throws -> Data { - try fixtureManifest("java-support.plugin.json") + try fixtureManifest("language-host-integration.plugin.json") } private func fixtureManifest(_ name: String) throws -> Data { diff --git a/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift b/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift index 8edbe9af4e..32f73872b5 100644 --- a/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift +++ b/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift @@ -361,7 +361,7 @@ struct PluginMarketplaceServiceTests { sourceKind: .official, platform: "darwin-arm64" ) - let php = try #require(index.plugins.first { $0.id == "dev.lithe.fixture.php-support" }) + let php = try #require(index.plugins.first { $0.id == "dev.lithe.fixture.language-packaged-server" }) #expect(php.latestVersion == "1.1.0") #expect(php.availability.download?.version == "1.0.0") #expect(php.installableVersion?.version == "1.0.0") @@ -374,7 +374,7 @@ struct PluginMarketplaceServiceTests { #expect(future.presentedVersion.version == "3.0.0") } - @Test(arguments: ["gradle-provider", "mix-provider"]) + @Test(arguments: ["build-provider", "build-provider-language-server"]) func buildProvidersAreAcceptedByTheMacOSHost(_ fixture: String) throws { let fixtures = DeclarativePluginTestSupport.repositoryRoot .appendingPathComponent("shared/contracts/plugins/v1/fixtures") @@ -393,7 +393,7 @@ struct PluginMarketplaceServiceTests { func changedRunArgumentsNeedApprovalAgainAfterStorage() throws { let fixtures = DeclarativePluginTestSupport.repositoryRoot .appendingPathComponent("shared/contracts/plugins/v1/fixtures") - let original = try Data(contentsOf: fixtures.appendingPathComponent("php-support.plugin.json")) + let original = try Data(contentsOf: fixtures.appendingPathComponent("language-packaged-server.plugin.json")) var manifest = try #require(JSONSerialization.jsonObject(with: original) as? [String: Any]) var providers = try #require(manifest["executionProviders"] as? [[String: Any]]) var run = try #require(providers[0]["run"] as? [String: Any]) diff --git a/macos/Tests/LitheTests/PluginPackageStoreTests.swift b/macos/Tests/LitheTests/PluginPackageStoreTests.swift index 99e0c3923c..6793100f10 100644 --- a/macos/Tests/LitheTests/PluginPackageStoreTests.swift +++ b/macos/Tests/LitheTests/PluginPackageStoreTests.swift @@ -314,7 +314,7 @@ struct PluginPackageStoreTests { try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sh") } - let pluginID = PluginID("dev.lithe.fixture.gradle-provider") + let pluginID = PluginID("dev.lithe.fixture.build-provider") #expect(throws: PluginPackageStoreError.invalidInstalledPlugin( pluginID, PluginPackageStoreError.buildProviderLaunchMissing( @@ -793,7 +793,7 @@ struct PluginPackageStoreTests { /// `launch` is given. A packaged launch gets an executable script. private func makeProviderPackage(root: URL, launch: [String: Any]?) throws -> URL { let fixtureURL = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json") + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider.plugin.json") var manifest = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] ) diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index 8979de7e6c..02f190c726 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -392,7 +392,7 @@ struct PluginWebBridgeTests { @Test func cargoExampleSelectionReachesTheRealCorePlan() async throws { let manifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json")) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json")) let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: manifest) let source = BuildSystemPluginSource( pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index fc629d2ee5..e52a9b53d3 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -12,7 +12,7 @@ struct RustBuildSystemOperationsTests { @Test func descriptorDeclaresBuildSystemsAndTheirModule() throws { - let descriptor = try Self.descriptor("cargo-build") + let descriptor = try Self.descriptor("build-static-file") let cargo = try #require(descriptor.buildSystems.first) #expect(cargo.id == "cargo") @@ -26,7 +26,7 @@ struct RustBuildSystemOperationsTests { #expect(descriptor.pluginManifest.modules.map(\.manifest.id) .contains(.buildSystemsExtension(descriptor.id.rawValue))) - let maven = try #require(Self.descriptor("maven-build").buildSystems.first) + let maven = try #require(Self.descriptor("build-host-integration").buildSystems.first) #expect(maven.modelSource == .hostIntegration) // A host-integration dependency source declares no scopes of its own. #expect(maven.dependencyScopes == [.workspace, .project]) @@ -36,7 +36,7 @@ struct RustBuildSystemOperationsTests { func detectionReadsStaticModelAndResolvesOptions() throws { let workspace = try Self.cargoWorkspace() defer { try? FileManager.default.removeItem(at: workspace) } - let source = try Self.source("cargo-build") + let source = try Self.source("build-static-file") let detection = try operations.detect( workspaceURL: workspace, @@ -78,7 +78,7 @@ struct RustBuildSystemOperationsTests { @Test func invocationAndVersionProbe() throws { - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let invocation = try operations.invocationPlan(target, root: ".") #expect(invocation.executables == [.command("cargo")]) @@ -90,7 +90,7 @@ struct RustBuildSystemOperationsTests { let old = try operations.checkToolVersion(target, runtime: nil, output: "cargo 1.60.0 (d1fd9fe 2022-03-01)\n") #expect(old.satisfied == false) - let go = try Self.target("go-build", "go") + let go = try Self.target("build-command-stdout", "go") let goInvocation = try operations.invocationPlan(go, root: ".") #expect(goInvocation.executables == [.runtime("go")]) #expect(goInvocation.runtimeDependencies.first?.hostManaged == true) @@ -101,7 +101,7 @@ struct RustBuildSystemOperationsTests { @Test func taskAndTestPlansCarryOptionsAndSelection() throws { - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let project = BuildModel.Project(id: "cargo:demo", path: "crates/demo", name: "demo") let context = BuildPlanContext(root: "crates/demo", options: ["release": .bool(true)]) @@ -142,7 +142,7 @@ struct RustBuildSystemOperationsTests { /// `refresh` and `tool` fields Core copies into the provider params. @Test func providerPlansCarryTheReloadReasonAndTheResolvedTool() throws { - let target = try Self.target("gradle-provider", "gradle") + let target = try Self.target("build-provider", "gradle") let context = BuildPlanContext(root: ".", options: [:]) let executable = URL(fileURLWithPath: "/opt/tools/gradle/bin/gradle") @@ -167,7 +167,7 @@ struct RustBuildSystemOperationsTests { @Test func commandModelPlansAndDecodesStandardOutput() throws { - let target = try Self.target("go-build", "go") + let target = try Self.target("build-command-stdout", "go") let workspace = URL(fileURLWithPath: "/tmp/lithe-go-workspace") let plan = try operations.modelPlan( @@ -197,7 +197,7 @@ struct RustBuildSystemOperationsTests { func diagnosticsAndSyncFingerprint() throws { let workspace = try Self.cargoWorkspace() defer { try? FileManager.default.removeItem(at: workspace) } - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let diagnostics = try operations.decodeDiagnostics( target, @@ -231,7 +231,7 @@ struct RustBuildSystemOperationsTests { to: workspace.appendingPathComponent("gradle/wrapper/gradle-wrapper.properties") ) for kind in ["maven", "gradle"] { - var manifest = try #require(JSONSerialization.jsonObject(with: Self.fixtureData("maven-build")) as? [String: Any]) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.fixtureData("build-host-integration")) as? [String: Any]) var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) systems[0]["languageImport"] = kind == "maven" ? ["kind": kind, "options": ["profiles": "profiles", "settingsFile": "settingsFile", "localRepository": "localRepository"]] @@ -259,7 +259,7 @@ struct RustBuildSystemOperationsTests { } } #expect(try operations.languageImport( - Self.target("cargo-build", "cargo"), workspaceURL: workspace, root: ".", options: [:], executableURL: nil + Self.target("build-static-file", "cargo"), workspaceURL: workspace, root: ".", options: [:], executableURL: nil ) == nil) } diff --git a/rust/lithe-core/src/tests/build_runtime_versions.rs b/rust/lithe-core/src/tests/build_runtime_versions.rs index 0c34076a58..d92dd50de9 100644 --- a/rust/lithe-core/src/tests/build_runtime_versions.rs +++ b/rust/lithe-core/src/tests/build_runtime_versions.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; fn manifest() -> Value { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/maven-build.plugin.json" + "/../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json" ))) .expect("Maven fixture is valid JSON") } diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 943e5c81f1..55b69c4b42 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -26,22 +26,23 @@ macro_rules! contract_file { }; } -const CARGO_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-build.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/gradle-build.plugin.json"); -const MAVEN_FIXTURE: &str = contract_file!("fixtures/maven-build.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); +const CARGO_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); +const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); +const MAVEN_FIXTURE: &str = contract_file!("fixtures/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const MIX_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-provider-language-server.plugin.json"); const BUILD_PLANS: &str = contract_file!("fixtures/build-plans.json"); fn fixture(name: &str) -> Value { let source = match name { - "cargo-build.plugin.json" => CARGO_FIXTURE, - "go-build.plugin.json" => GO_FIXTURE, - "gradle-build.plugin.json" => GRADLE_FIXTURE, - "maven-build.plugin.json" => MAVEN_FIXTURE, - "gradle-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, - "mix-provider.plugin.json" => MIX_PROVIDER_FIXTURE, + "build-static-file.plugin.json" => CARGO_FIXTURE, + "build-command-stdout.plugin.json" => GO_FIXTURE, + "build-command-output-file.plugin.json" => GRADLE_FIXTURE, + "build-host-integration.plugin.json" => MAVEN_FIXTURE, + "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, + "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -126,12 +127,12 @@ fn substitute(value: &mut Value, workspace: &str) { #[test] fn build_fixtures_validate() { for name in [ - "cargo-build.plugin.json", - "go-build.plugin.json", - "gradle-build.plugin.json", - "maven-build.plugin.json", - "gradle-provider.plugin.json", - "mix-provider.plugin.json", + "build-static-file.plugin.json", + "build-command-stdout.plugin.json", + "build-command-output-file.plugin.json", + "build-host-integration.plugin.json", + "build-provider.plugin.json", + "build-provider-language-server.plugin.json", ] { let outcome = validate(fixture(name)); assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); @@ -228,7 +229,7 @@ fn cargo_run_entries_match_the_builtin_detector() { ); let detection = detect( &workspace, - vec![fixture("cargo-build.plugin.json")], + vec![fixture("build-static-file.plugin.json")], json!({}), ); let mut planned: Vec = detection["instances"] @@ -316,7 +317,7 @@ fn cargo_implicit_main_survives_test_and_example_targets() { .write("cli/src/bin/cli-bin.rs", "fn main() {}\n"); let detection = detect( &workspace, - vec![fixture("cargo-build.plugin.json")], + vec![fixture("build-static-file.plugin.json")], json!({}), ); let targets = |id: &str| -> Vec<(String, String)> { @@ -376,18 +377,18 @@ fn detection_applies_nesting_priority_and_rejections() { ) .write("target/debug/Cargo.toml", "[package]\nname = \"ignored\"\n"); - let mut preferred = fixture("cargo-build.plugin.json"); + let mut preferred = fixture("build-static-file.plugin.json"); preferred["id"] = json!("dev.lithe.fixture.cargo-preferred"); preferred["buildSystems"][0]["detection"]["priority"] = json!(80); - let mut broken = fixture("go-build.plugin.json"); + let mut broken = fixture("build-command-stdout.plugin.json"); broken["permissions"] = json!([]); let detection = detect( &workspace, vec![ - fixture("cargo-build.plugin.json"), + fixture("build-static-file.plugin.json"), broken, - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), preferred, ], json!({}), @@ -408,7 +409,7 @@ fn detection_applies_nesting_priority_and_rejections() { assert_eq!( detection["shadowed"], json!([{ - "pluginID": "dev.lithe.fixture.cargo-build", + "pluginID": "dev.lithe.fixture.build-static-file", "buildSystemID": "cargo", "by": "dev.lithe.fixture.cargo-preferred", }]) @@ -455,7 +456,7 @@ fn maven_method_durations_survive_report_aggregation_and_build_decoding() { ); let decoded = call( "decodeTestResults", - fixture("maven-build.plugin.json"), + fixture("build-host-integration.plugin.json"), "maven", json!({ "workspaceRoot": workspace.root(), "root": ".", "project": ".", "output": "", "notBeforeMillis": 0 @@ -492,7 +493,7 @@ fn maven_reactor_detects_through_the_host_integration() { .write("backend/api/src/main/java/demo/App.java", "class App {}\n"); let detection = detect( &workspace, - vec![fixture("maven-build.plugin.json")], + vec![fixture("build-host-integration.plugin.json")], json!({ "maven": { "profiles": ["dev", "removed"] } }), ); assert_eq!(instance_ids(&detection), ["maven:backend"]); @@ -527,7 +528,7 @@ fn maven_task_plans_match_the_builtin_arguments() { let settings = "/opt/maven/settings.xml"; let outcome = call( "taskPlan", - fixture("maven-build.plugin.json"), + fixture("build-host-integration.plugin.json"), "maven", json!({ "root": ".", @@ -598,7 +599,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let model = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -641,7 +642,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let dependencies = call( "decodeDependencies", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -661,7 +662,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let missing = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": "." }), ); @@ -692,7 +693,7 @@ fn junit_reports_become_case_results() { ); let outcome = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -737,7 +738,7 @@ fn junit_reports_become_case_results() { let later = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -770,7 +771,7 @@ fn workspace_test_results_cover_every_model_project() { let decode = |request: Value| { call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", request, ) @@ -848,7 +849,7 @@ fn canonical_model_paths_stay_inside_the_instance_root() { workspace.write("model.json", &document.to_string()); let result = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": root, "outputFile": workspace.0.join("model.json") @@ -881,7 +882,7 @@ fn canonical_model_paths_stay_inside_the_instance_root() { #[test] fn selector_model_paths_stay_inside_the_instance_root() { let workspace = TemporaryWorkspace::new("selector-model-boundary"); - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["model"] = json!({ "source": "command", "arguments": ["model"], "output": "stdout", "decoder": "jsonStream", "executesProjectCode": false, "mayUseNetwork": false, @@ -942,7 +943,7 @@ fn selector_model_paths_stay_inside_the_instance_root() { #[test] fn rejected_selector_paths_do_not_suppress_valid_fallbacks() { let workspace = TemporaryWorkspace::new("selector-model-boundary-fallback"); - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["model"] = json!({ "source": "command", "arguments": ["model"], "output": "stdout", "decoder": "jsonStream", "executesProjectCode": false, "mayUseNetwork": false, @@ -1001,7 +1002,7 @@ fn canonical_content_roots_are_relative_to_the_instance_root() { ); let outcome = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -1042,7 +1043,7 @@ fn sync_fingerprint_follows_watched_files() { let fingerprint = || { let outcome = call( "syncFingerprint", - fixture("cargo-build.plugin.json"), + fixture("build-static-file.plugin.json"), "cargo", json!({ "workspaceRoot": workspace.root(), "root": ".", "projects": ["."] }), ); @@ -1070,7 +1071,7 @@ fn sync_fingerprint_follows_watched_files() { fn resolve_options_describe_explicit_empty_text_values() { let outcome = call( "resolveOptions", - fixture("go-build.plugin.json"), + fixture("build-command-stdout.plugin.json"), "go", json!({ "options": { "tags": "" } }), ); @@ -1087,7 +1088,7 @@ fn resolve_options_describe_explicit_empty_text_values() { #[test] fn option_arguments_may_repeat_under_opposite_conditions() { - let mut manifest = fixture("cargo-build.plugin.json"); + let mut manifest = fixture("build-static-file.plugin.json"); manifest["buildSystems"][0]["test"]["arguments"] = json!([ "test", { "value": "${optionArguments}", "when": "targetSelected" }, @@ -1117,7 +1118,7 @@ fn provider_processes_are_install_review_programs() { assert_eq!(outcome["status"], "ok", "{outcome}"); outcome["review"]["programs"].as_array().unwrap().clone() }; - let own = programs("gradle-provider.plugin.json"); + let own = programs("build-provider.plugin.json"); let provider = own .iter() .find(|program| program["kind"] == "runtimeBuildProvider") @@ -1131,7 +1132,7 @@ fn provider_processes_are_install_review_programs() { assert_eq!(provider["executesProjectCode"], true); assert!(own.contains(&json!({ "kind": "runtime", "tool": "java", "minimumVersion": "17" }))); - let shared = programs("mix-provider.plugin.json"); + let shared = programs("build-provider-language-server.plugin.json"); let build_system = shared .iter() .find(|program| program["kind"] == "buildSystem") @@ -1158,7 +1159,7 @@ fn cargo_run_entries_drop_features_removed_from_the_model() { "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[features]\ncli = []\n", ) .write("src/main.rs", "fn main() {}\n"); - let manifest = fixture("cargo-build.plugin.json"); + let manifest = fixture("build-static-file.plugin.json"); let options = json!({ "features": ["cli", "removed"] }); let detection = detect( &workspace, @@ -1192,7 +1193,7 @@ fn cargo_run_entries_drop_features_removed_from_the_model() { /// A changed default can select a different script without changing its argv template. #[test] fn build_option_defaults_require_install_review_again() { - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["options"] .as_array_mut() .unwrap() @@ -1238,7 +1239,7 @@ fn static_models_and_sync_fingerprints_reject_escaping_symlinks() { let outside = TemporaryWorkspace::new("build-external-files"); outside.write("Cargo.toml", "[package]\nname = \"private\"\n"); symlink(outside.0.join("Cargo.toml"), workspace.0.join("Cargo.toml")).unwrap(); - let manifest = fixture("cargo-build.plugin.json"); + let manifest = fixture("build-static-file.plugin.json"); let decoded = call( "decodeModel", manifest.clone(), @@ -1299,7 +1300,7 @@ fn maven_models_do_not_read_poms_outside_the_workspace() { let outside = TemporaryWorkspace::new("build-external-maven"); outside.write("pom.xml", "4.0.0private-artifact9.9"); symlink(outside.0.join("pom.xml"), workspace.0.join("pom.xml")).unwrap(); - let manifest = fixture("maven-build.plugin.json"); + let manifest = fixture("build-host-integration.plugin.json"); let decoded = call( "decodeModel", manifest.clone(), @@ -1340,7 +1341,7 @@ fn junit_reports_do_not_follow_escaping_project_directories() { symlink(outside.0.join("app"), workspace.0.join("app")).unwrap(); let outcome = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": ".", "project": "app", "output": "", @@ -1368,7 +1369,7 @@ fn reported_output_paths_reject_escaping_symlinks() { symlink(workspace.0.join("src"), workspace.0.join("alias")).unwrap(); symlink(workspace.0.join("src"), workspace.0.join("app/alias")).unwrap(); - let manifest = fixture("gradle-build.plugin.json"); + let manifest = fixture("build-command-output-file.plugin.json"); let diagnostics = |manifest: &Value, output: &str| { call( "decodeDiagnostics", @@ -1467,8 +1468,7 @@ fn launch_plans_name_exactly_one_process() { json!({}), json!({ "languageID": "elixir", "buildSystemID": "mix" }), ] { - let mut payload = - json!({ "manifest": fixture("mix-provider.plugin.json"), "request": request }); + let mut payload = json!({ "manifest": fixture("build-provider-language-server.plugin.json"), "request": request }); payload .as_object_mut() .unwrap() @@ -1489,7 +1489,7 @@ fn provider_models_are_not_read_during_detection() { workspace.write("mix.exs", "").write("apps/web/mix.exs", ""); let detection = detect( &workspace, - vec![fixture("mix-provider.plugin.json")], + vec![fixture("build-provider-language-server.plugin.json")], json!({}), ); assert_eq!(instance_ids(&detection), ["mix:."]); @@ -1504,7 +1504,7 @@ fn provider_models_are_not_read_during_detection() { #[test] fn task_categories_and_option_placements_are_display_only() { let workspace = TemporaryWorkspace::new("build-task-category"); - let mut gradle = fixture("gradle-build.plugin.json"); + let mut gradle = fixture("build-command-output-file.plugin.json"); let build_system = &mut gradle["buildSystems"][0]; build_system["icon"] = json!({ "path": "icons/gradle.svg" }); build_system["tasks"][0]["category"] = json!(["Tasks", "other"]); diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 230b408bf3..371ebeda01 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -15,18 +15,19 @@ macro_rules! contract_file { }; } -const PHP_FIXTURE: &str = contract_file!("fixtures/php-support.plugin.json"); +const PHP_FIXTURE: &str = contract_file!("fixtures/language-packaged-server.plugin.json"); const HELLO_FIXTURE: &str = contract_file!("fixtures/hello-lithe.plugin.json"); const WEB_PANEL_FIXTURE: &str = contract_file!("fixtures/web-panel.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-support.plugin.json"); -const JAVA_FIXTURE: &str = contract_file!("fixtures/java-support.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); +const JAVA_FIXTURE: &str = contract_file!("fixtures/language-host-integration.plugin.json"); const NODE_RUNTIME_FIXTURE: &str = contract_file!("fixtures/node-runtime.plugin.json"); -const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); -const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/go-build.plugin.json"); -const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/gradle-build.plugin.json"); -const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/maven-build.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); +const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); +const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); +const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); +const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const MIX_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-provider-language-server.plugin.json"); const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings-v2.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); @@ -38,18 +39,18 @@ const OFFICIAL_JAVA: &str = include_str!(concat!( fn fixture(name: &str) -> Value { let source = match name { - "php-support.plugin.json" => PHP_FIXTURE, - "go-support.plugin.json" => GO_FIXTURE, + "language-packaged-server.plugin.json" => PHP_FIXTURE, + "language-system-command.plugin.json" => GO_FIXTURE, "hello-lithe.plugin.json" => HELLO_FIXTURE, "web-panel.plugin.json" => WEB_PANEL_FIXTURE, - "java-support.plugin.json" => JAVA_FIXTURE, + "language-host-integration.plugin.json" => JAVA_FIXTURE, "node-runtime.plugin.json" => NODE_RUNTIME_FIXTURE, - "cargo-build.plugin.json" => CARGO_BUILD_FIXTURE, - "go-build.plugin.json" => GO_BUILD_FIXTURE, - "gradle-build.plugin.json" => GRADLE_BUILD_FIXTURE, - "maven-build.plugin.json" => MAVEN_BUILD_FIXTURE, - "gradle-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, - "mix-provider.plugin.json" => MIX_PROVIDER_FIXTURE, + "build-static-file.plugin.json" => CARGO_BUILD_FIXTURE, + "build-command-stdout.plugin.json" => GO_BUILD_FIXTURE, + "build-command-output-file.plugin.json" => GRADLE_BUILD_FIXTURE, + "build-host-integration.plugin.json" => MAVEN_BUILD_FIXTURE, + "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, + "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, "settings-v2.plugin.json" => SETTINGS_V2_FIXTURE, other => panic!("unknown fixture {other}"), }; @@ -78,7 +79,7 @@ fn contract_fixtures_and_official_plugins_validate() { #[test] fn validation_normalizes_order_and_spelling_without_touching_arguments() { - let mut manifest = fixture("php-support.plugin.json"); + let mut manifest = fixture("language-packaged-server.plugin.json"); manifest["languages"][0]["fileExtensions"] = json!(["PHTML", ".php", "php"]); manifest["permissions"] = json!(["process.test", "process.run", "languageServer.launch"]); let outcome = validate(manifest); @@ -103,7 +104,7 @@ fn validation_normalizes_order_and_spelling_without_touching_arguments() { /// `.toolrc` keep matching after normalization. #[test] fn validation_keeps_the_leading_dot_of_hidden_file_names() { - let mut manifest = fixture("go-support.plugin.json"); + let mut manifest = fixture("language-system-command.plugin.json"); manifest["languages"][0]["fileExtensions"] = json!([".go"]); manifest["languages"][0]["fileNames"] = json!([".toolrc"]); manifest["languages"][0]["projectFileNames"] = json!(["go.mod", ".env.example"]); @@ -149,7 +150,7 @@ fn invalid_manifest_fixtures_report_their_stable_codes() { /// feature that integration has, with its files, and with no second owner. #[test] fn host_integration_capabilities_are_normalized_and_checked_against_the_integration() { - let mut manifest = fixture("java-support.plugin.json"); + let mut manifest = fixture("language-host-integration.plugin.json"); let capabilities = "/languageServers/0/hostIntegration/capabilities"; *manifest.pointer_mut(capabilities).unwrap() = json!(["testing", "debug", "testing"]); let outcome = validate(manifest.clone()); @@ -178,12 +179,12 @@ fn host_integration_capabilities_are_normalized_and_checked_against_the_integrat }; let field = "languageServers[0].hostIntegration.capabilities"; - let mut unknown = fixture("java-support.plugin.json"); + let mut unknown = fixture("language-host-integration.plugin.json"); *unknown.pointer_mut(capabilities).unwrap() = json!(["formatting"]); assert_eq!(rejected_field(unknown), field); // Debugging loads the debugger as a JDT LS extension bundle. - let mut without_bundles = fixture("java-support.plugin.json"); + let mut without_bundles = fixture("language-host-integration.plugin.json"); *without_bundles.pointer_mut(capabilities).unwrap() = json!(["debug"]); let resources = "/languageServers/0/hostIntegration/resources"; let resources = without_bundles.pointer_mut(resources).unwrap(); @@ -193,7 +194,7 @@ fn host_integration_capabilities_are_normalized_and_checked_against_the_integrat .remove("extensionBundleDirectories"); assert_eq!(rejected_field(without_bundles), field); - let mut owned_twice = fixture("java-support.plugin.json"); + let mut owned_twice = fixture("language-host-integration.plugin.json"); owned_twice["permissions"] = json!(["languageServer.launch", "process.run"]); owned_twice["activationEvents"] = json!(["onLanguage:java", "onRun:java"]); owned_twice["executionProviders"] = json!([{ @@ -248,7 +249,7 @@ fn command_plan_fixtures_match_golden_expansions() { /// would change the command line of an installed plugin. #[test] fn api_level_1_language_server_arguments_stay_literal() { - let mut manifest = fixture("php-support.plugin.json"); + let mut manifest = fixture("language-packaged-server.plugin.json"); manifest["languageServers"][0]["arguments"] = json!(["--stdio", "--log=${pluginDir}"]); assert_eq!(validate(manifest.clone())["status"], "ok"); let outcome = language_server_launch_plan_command(json!({ @@ -274,7 +275,7 @@ fn api_level_1_language_server_arguments_stay_literal() { #[test] fn runtime_launch_and_initialization_options_are_reviewed_programs() { let outcome = crate::plugins::marketplace::install_review_command(json!({ - "manifest": fixture("java-support.plugin.json"), + "manifest": fixture("language-host-integration.plugin.json"), })) .expect("payload is well formed"); let programs = outcome["review"]["programs"].as_array().unwrap(); @@ -327,7 +328,7 @@ fn runtime_version_fixtures_match_golden_outcomes() { /// so it cannot depend on one. #[test] fn validation_probe_cannot_use_settings() { - let mut manifest = fixture("go-support.plugin.json"); + let mut manifest = fixture("language-system-command.plugin.json"); manifest["pluginAPIVersion"] = json!(2); manifest["settings"] = json!([ { "id": "verbose", "type": "boolean", "title": "Verbose", "default": false } @@ -370,14 +371,14 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { outcome["error"]["field"].as_str().unwrap().to_string(), ) }; - let mix = fixture("mix-provider.plugin.json"); + let mix = fixture("build-provider-language-server.plugin.json"); let outcome = validate(mix.clone()); assert_eq!(outcome["status"], "ok", "{outcome}"); assert_eq!( outcome["plugin"]["requires"], json!({ "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }) ); - let legacy = validate(fixture("php-support.plugin.json")); + let legacy = validate(fixture("language-packaged-server.plugin.json")); assert!(legacy["plugin"].get("requires").is_none(), "{legacy}"); let mut future = mix.clone(); @@ -404,7 +405,7 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { ); // A level 3 plugin without build systems never depends on them. - let mut language_only = fixture("php-support.plugin.json"); + let mut language_only = fixture("language-packaged-server.plugin.json"); language_only["pluginAPIVersion"] = json!(3); language_only["requires"] = json!({ "executionProviders": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 @@ -433,7 +434,7 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { assert_eq!(validate_on(&mix, &host_with_builds)["status"], "ok"); // Capability versions do not apply below level 3. assert_eq!( - validate_on(&fixture("php-support.plugin.json"), &json!({}))["status"], + validate_on(&fixture("language-packaged-server.plugin.json"), &json!({}))["status"], "ok" ); } @@ -447,14 +448,14 @@ fn build_providers_require_their_own_host_capability() { "serverProcess": [1], "settings": [1] }); let template = validate_command(json!({ - "manifest": fixture("cargo-build.plugin.json"), "hostCapabilities": host + "manifest": fixture("build-static-file.plugin.json"), "hostCapabilities": host })) .unwrap(); assert_eq!(template["status"], "ok", "{template}"); for (name, build_system_id) in [ - ("gradle-provider.plugin.json", "gradle"), - ("mix-provider.plugin.json", "mix"), + ("build-provider.plugin.json", "gradle"), + ("build-provider-language-server.plugin.json", "mix"), ] { let manifest = fixture(name); for versions in [None, Some(json!([2])), Some(json!([1]))] { diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index 170b1b29a2..38ec2e802a 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -18,11 +18,11 @@ const INDEX_CASES: &str = contract_file!("fixtures/marketplace/index-cases.json" const INVALID_ENTRIES: &str = contract_file!("fixtures/marketplace/invalid-index-entries.json"); const SOURCE_URLS: &str = contract_file!("fixtures/marketplace/source-urls.json"); const INSTALL_REVIEWS: &str = contract_file!("fixtures/marketplace/install-reviews.json"); -const PHP_FIXTURE: &str = contract_file!("fixtures/php-support.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-support.plugin.json"); -const MIX_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const CARGO_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); +const PHP_FIXTURE: &str = contract_file!("fixtures/language-packaged-server.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); +const MIX_FIXTURE: &str = contract_file!("fixtures/build-provider-language-server.plugin.json"); +const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const CARGO_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); const OFFICIAL_INDEX: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../Plugins/marketplace/index.json" @@ -34,11 +34,11 @@ fn json_file(source: &str) -> Value { fn manifest_fixture(name: &str) -> Value { match name { - "php-support.plugin.json" => json_file(PHP_FIXTURE), - "go-support.plugin.json" => json_file(GO_FIXTURE), - "mix-provider.plugin.json" => json_file(MIX_FIXTURE), - "gradle-provider.plugin.json" => json_file(GRADLE_FIXTURE), - "cargo-build.plugin.json" => json_file(CARGO_FIXTURE), + "language-packaged-server.plugin.json" => json_file(PHP_FIXTURE), + "language-system-command.plugin.json" => json_file(GO_FIXTURE), + "build-provider-language-server.plugin.json" => json_file(MIX_FIXTURE), + "build-provider.plugin.json" => json_file(GRADLE_FIXTURE), + "build-static-file.plugin.json" => json_file(CARGO_FIXTURE), other => panic!("unknown fixture {other}"), } } diff --git a/rust/lithe-core/tests/plugin_sdk/mod.rs b/rust/lithe-core/tests/plugin_sdk/mod.rs deleted file mode 100644 index cad0fd45c3..0000000000 --- a/rust/lithe-core/tests/plugin_sdk/mod.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! End-to-end scenario shared by the official plugin SDKs (`sdk/node`, -//! `sdk/java`), run against Rust Core through the same JSON commands a host -//! sends. -//! -//! Each SDK's sample backend is launched exactly as a host launches a build -//! provider: Core validates its manifest and expands the launch plan, the -//! runtime version is checked, and `lsp.startServer` starts it. The scenario -//! then drives the typed build requests, a multi-step plugin function with -//! progress, a plugin failure, cancellation of a running tool, runtime -//! registration, and state recovery across a restart. Both samples answer it -//! identically, which keeps the SDKs' behaviour aligned. - -// Each test binary includes this module and uses only the helpers its SDK needs. -#![allow(dead_code)] - -use lithe_core::execute_json; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, Instant}; - -const BUILD_SYSTEM: &str = "sample"; -/// Bound for any single wait; a broken backend fails here instead of in CI. -const WAIT: Duration = Duration::from_secs(20); - -fn core(command: &str, payload: Value) -> Value { - let request = json!({ "id": format!("sdk-{command}"), "command": command, "payload": payload }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Core response should be JSON"); - assert_eq!(response["ok"], true, "{command} failed: {response}"); - response["data"].clone() -} - -fn repository_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .expect("repository root") -} - -/// Every file of a directory with its contents, to prove the package is unchanged. -fn listing(root: &Path) -> BTreeMap> { - let mut files = BTreeMap::new(); - let mut pending = vec![root.to_path_buf()]; - while let Some(directory) = pending.pop() { - for entry in fs::read_dir(&directory).expect("readable directory") { - let path = entry.expect("directory entry").path(); - if path.is_dir() { - pending.push(path); - } else { - let contents = fs::read(&path).expect("readable file"); - files.insert(path.strip_prefix(root).unwrap().to_path_buf(), contents); - } - } - } - files -} - -/// Temporary workspace and host state directories, removed even when an -/// assertion fails. -struct Fixture { - root: PathBuf, -} - -impl Fixture { - fn new(label: &str) -> Self { - let root = - std::env::temp_dir().join(format!("lithe-plugin-sdk-{label}-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("fixture directory"); - // The backend sees canonical paths (macOS /var is /private/var). - Self { - root: root.canonicalize().expect("fixture path"), - } - } - - fn workspace(&self) -> PathBuf { - self.root.join("workspace") - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } -} - -/// One running backend session. Dropping it stops the process and removes -/// the session, so a failed assertion leaves no backend behind. -struct Session { - id: String, - plugin_id: String, - events: Vec, -} - -impl Session { - fn start(plugin_id: &str, executable: &str, arguments: &[Value], workspace: &Path) -> Self { - let started = core( - "lsp.startServer", - json!({ - "providerId": format!("buildProvider:{plugin_id}:{BUILD_SYSTEM}"), - "pluginId": plugin_id, - "executablePath": executable, - "arguments": arguments, - // Directory URI with a trailing slash, as the macOS host sends it. - "rootUri": format!("file://{}/", workspace.display()), - "workingDirectory": workspace, - "initializeTimeoutMilliseconds": WAIT.as_millis() as u64, - "requestTimeoutMilliseconds": WAIT.as_millis() as u64, - "shutdownTimeoutMilliseconds": 5000, - }), - ); - let mut session = Self { - id: started["sessionId"] - .as_str() - .expect("session ID") - .to_string(), - plugin_id: plugin_id.to_string(), - events: Vec::new(), - }; - session.await_event("the session to become ready", |event| { - event["type"] == "stateChanged" && event["state"] == "ready" - }); - session - } - - /// Waits for the first event, already received or new, that `matches`. - fn await_event(&mut self, description: &str, matches: impl Fn(&Value) -> bool) -> Value { - let deadline = Instant::now() + WAIT; - loop { - if let Some(event) = self.events.iter().find(|event| matches(event)) { - return event.clone(); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - assert!( - !remaining.is_zero(), - "timed out waiting for {description}; events: {:#?}", - self.events - ); - let drained = core( - "lsp.waitEvents", - json!({ "sessionId": self.id, "timeoutMilliseconds": remaining.as_millis().min(1000) as u64 }), - ); - self.events - .extend(drained["events"].as_array().cloned().unwrap_or_default()); - } - } - - fn completion(&mut self, operation: &str) -> Value { - self.await_event(&format!("operation {operation} to end"), |event| { - // Every terminal outcome, cancellation included, is one requestCompleted. - event["operationId"] == operation && event["type"] == "requestCompleted" - }) - } - - fn invoke(&mut self, operation: &str, function: &str, params: Value) { - core( - "lsp.functionRequest", - json!({ - "sessionId": self.id, - "operationId": operation, - "function": { "pluginID": self.plugin_id, "functionID": function }, - "params": params, - }), - ); - } - - fn call(&mut self, operation: &str, function: &str, params: Value) -> Value { - self.invoke(operation, function, params); - self.completion(operation) - } - - /// The registry after the latest `functionsChanged` event. - fn functions(&mut self) -> Vec { - let latest = self - .events - .iter() - .rev() - .find(|event| event["type"] == "functionsChanged") - .expect("a functionsChanged event"); - serde_json::from_value(latest["result"]["functions"].clone()).expect("function IDs") - } - - fn build_request(&mut self, operation: &str, plan: &Value) -> Value { - core( - "lsp.buildRequest", - json!({ - "sessionId": self.id, - "operationId": operation, - "method": plan["provider"]["method"], - "params": plan["provider"]["params"], - }), - ); - let completed = self.completion(operation); - assert!(completed.get("error").is_none(), "{completed}"); - completed["result"]["value"].clone() - } - - fn stop(&mut self) { - core("lsp.stopServer", json!({ "sessionId": self.id })); - self.await_event("the backend to stop", |event| { - event["type"] == "stateChanged" && event["state"] == "stopped" - }); - } -} - -impl Drop for Session { - fn drop(&mut self) { - let request = json!({ "id": "sdk-cleanup", "command": "lsp.stopServer", "payload": { "sessionId": self.id } }); - let _ = execute_json(&request.to_string()); - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - let destroy = json!({ "id": "sdk-cleanup", "command": "lsp.destroyServer", "payload": { "sessionId": self.id } }); - let response: Value = - serde_json::from_str(&execute_json(&destroy.to_string())).unwrap_or(Value::Null); - if response["ok"] == true { - return; - } - let wait = json!({ "id": "sdk-cleanup", "command": "lsp.waitEvents", "payload": { "sessionId": self.id, "timeoutMilliseconds": 200 } }); - let _ = execute_json(&wait.to_string()); - } - } -} - -#[cfg(unix)] -fn is_alive(pid: u32) -> bool { - Command::new("kill") - .args(["-0", &pid.to_string()]) - .status() - .is_ok_and(|status| status.success()) -} - -/// One SDK's sample backend, packaged as a host installs it. -pub struct Sample { - /// `launch.tool` of the sample's provider: `node` or `java`. - pub tool: &'static str, - /// The runtime executable the host would resolve for that tool. - pub runtime: String, - /// The installed plugin package: `plugin.json` and the launch entry. - pub package: PathBuf, - /// Node.js, which runs the sample workspace's build tools for both SDKs. - pub node: String, -} - -/// Node.js from `LITHE_PLUGIN_SDK_NODE`, or `None` with a skip notice. -pub fn node_from_environment(test: &str) -> Option { - let Some(node) = std::env::var_os("LITHE_PLUGIN_SDK_NODE") else { - eprintln!("skipped: set LITHE_PLUGIN_SDK_NODE to run {test}"); - return None; - }; - Some(node.into_string().expect("UTF-8 Node.js path")) -} - -pub fn repository_path(relative: &str) -> PathBuf { - repository_root().join(relative) -} - -/// Copies `files` (package-relative destination, source) into a fresh package -/// directory under the system temporary directory, removed on drop. -pub struct TemporaryPackage { - pub root: PathBuf, -} - -impl TemporaryPackage { - pub fn new(label: &str, files: &[(&str, PathBuf)]) -> Self { - let root = std::env::temp_dir().join(format!( - "lithe-plugin-sdk-package-{label}-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - for (destination, source) in files { - let target = root.join(destination); - fs::create_dir_all(target.parent().expect("package file parent")) - .expect("package directory"); - fs::copy(source, &target) - .unwrap_or_else(|error| panic!("copy {}: {error}", source.display())); - } - Self { - root: root.canonicalize().expect("package path"), - } - } -} - -impl Drop for TemporaryPackage { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } -} - -/// Runs the shared scenario against `sample`. -pub fn run_sample_scenario(sample: &Sample) { - let package = &sample.package; - let node = &sample.node; - let runtime = &sample.runtime; - let manifest: Value = - serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) - .expect("sample manifest JSON"); - let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); - let package_before = listing(&package); - - // The manifest passes the same validation as an installed plugin. - let validated = core( - "plugins.declarative.validate", - json!({ "manifest": manifest }), - ); - assert_eq!(validated["status"], "ok", "{validated}"); - - let fixture = Fixture::new(sample.tool); - let workspace = fixture.workspace(); - for project in ["app", "broken", "slow"] { - fs::create_dir_all(workspace.join(project)).expect("project directory"); - } - let slow_pid = workspace.join("slow/slow.pid"); - fs::write( - workspace.join("lithe-sample.json"), - json!({ - "projects": [ - { - "name": "app", "path": "app", - "build": [node, "-e", "console.log('built app')"], - "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] - }, - { "name": "broken", "path": "broken", "build": [node, "-e", "process.exit(2)"] }, - { - "name": "slow", "path": "slow", - "build": [node, "-e", "require('fs').writeFileSync('slow.pid', String(process.pid)); setInterval(() => {}, 1000)"] - } - ] - }) - .to_string(), - ) - .expect("workspace file"); - - // The host expands the launch plan and checks the runtime it found. - let plan = core( - "plugins.declarative.languageServerLaunchPlan", - json!({ - "manifest": manifest, - "buildSystemID": BUILD_SYSTEM, - "request": { - "pluginDirectory": package, - "stateDirectory": fixture.root.join("state"), - "workspaceStateDirectory": fixture.root.join("workspace-state"), - "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, - } - }), - ); - assert_eq!(plan["status"], "ok", "{plan}"); - let plan = &plan["plan"]; - assert_eq!(plan["launch"]["tool"], sample.tool); - let version = Command::new(runtime) - .args( - plan["launch"]["versionArguments"] - .as_array() - .expect("version probe") - .iter() - .map(|argument| argument.as_str().unwrap()), - ) - .output() - .expect("runtime version probe"); - // Hosts capture stderr with stdout: `java -version` prints to stderr. - let output = format!( - "{}{}", - String::from_utf8_lossy(&version.stdout), - String::from_utf8_lossy(&version.stderr) - ); - let check = core( - "plugins.declarative.checkRuntimeVersion", - json!({ - "tool": sample.tool, - "minimumVersion": plan["launch"]["minimumVersion"], - "output": output, - }), - ); - assert_eq!(check["check"]["satisfied"], true, "{check}"); - for directory in plan["requiredDirectories"].as_array().unwrap() { - let path = match directory.as_str() { - Some("state") => fixture.root.join("state"), - _ => fixture.root.join("workspace-state"), - }; - fs::create_dir_all(path).expect("host state directory"); - } - let arguments = plan["arguments"].as_array().unwrap().clone(); - - let mut session = Session::start(&plugin_id, runtime, &arguments, &workspace); - assert_eq!(session.functions(), ["snapshot.read", "workspace.build"]); - - // Typed build requests: Core plans them and decodes the answers. - let build = |operation: &str, request: Value| { - core( - operation, - json!({ "manifest": manifest, "buildSystemID": BUILD_SYSTEM, "request": request }), - ) - }; - let model_plan = build("build.modelPlan", json!({ "root": "." }))["plan"].clone(); - assert_eq!(model_plan["provider"]["method"], "lithe/build/model"); - let model_result = session.build_request("model", &model_plan); - let model = build( - "build.decodeModel", - json!({ "workspaceRoot": workspace, "root": ".", "result": model_result }), - ); - let names: Vec<_> = model["model"]["projects"] - .as_array() - .expect("decoded projects") - .iter() - .map(|project| project["name"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(names, ["app", "broken", "slow"], "{model}"); - - let dependency_plan = build( - "build.dependencyPlan", - json!({ "root": ".", "project": { "name": "app", "path": "app" } }), - )["plan"] - .clone(); - let dependencies_result = session.build_request("dependencies", &dependency_plan); - let dependencies = build( - "build.decodeDependencies", - json!({ "workspaceRoot": workspace, "root": ".", "project": "app", "result": dependencies_result }), - ); - assert_eq!( - dependencies["dependencies"]["dependencies"][0]["name"], "left-pad", - "{dependencies}" - ); - - // A multi-step function: prepare, run the tool, record a snapshot. - let built = session.call( - "build-app", - "workspace.build", - json!({ "projects": ["app"] }), - ); - assert_eq!( - built["result"]["value"], - json!({ "generation": 1, "status": "succeeded", "projects": [{ "name": "app", "exitCode": 0 }] }), - "{built}" - ); - let progress: Vec<_> = session - .events - .iter() - .filter(|event| event["type"] == "requestProgress" && event["operationId"] == "build-app") - .map(|event| event["result"]["message"].clone()) - .collect(); - assert_eq!(progress, ["Preparing", "Building app", "Done"]); - // The backend registered snapshot.clear once a snapshot existed. - session.await_event("snapshot.clear to be registered", |event| { - event["type"] == "functionsChanged" - && event["result"]["functions"] - .as_array() - .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) - }); - - // A plugin failure keeps Lithe's stable code and forwards the plugin's own. - let failed = session.call( - "build-broken", - "workspace.build", - json!({ "projects": ["broken"] }), - ); - assert_eq!( - failed["error"]["code"], "plugin.function.failed", - "{failed}" - ); - assert_eq!(failed["error"]["pluginErrorCode"], "sample.buildFailed"); - - // Cancelling a running build ends the tool the backend started. - session.invoke( - "build-slow", - "workspace.build", - json!({ "projects": ["slow"] }), - ); - let deadline = Instant::now() + WAIT; - let pid = loop { - if let Some(pid) = fs::read_to_string(&slow_pid) - .ok() - .and_then(|text| text.trim().parse::().ok()) - { - break pid; - } - assert!(Instant::now() < deadline, "the slow tool never started"); - session.events.extend( - core( - "lsp.waitEvents", - json!({ "sessionId": session.id, "timeoutMilliseconds": 50 }), - )["events"] - .as_array() - .cloned() - .unwrap_or_default(), - ); - }; - core( - "lsp.cancelOperation", - json!({ "sessionId": session.id, "operationId": "build-slow" }), - ); - assert_eq!( - session.completion("build-slow")["error"]["code"], - "requestCancelled" - ); - #[cfg(unix)] - { - let deadline = Instant::now() + WAIT; - while is_alive(pid) { - assert!( - Instant::now() < deadline, - "the cancelled tool {pid} is still running" - ); - // test-stability: allow(rust-real-sleep) reason: the cancelled tool is a separate OS process that offers no notification channel to the test; the deadline above bounds the loop. - std::thread::sleep(Duration::from_millis(20)); - } - } - #[cfg(not(unix))] - let _ = pid; - - // State survives a restart: the new process reads the recorded snapshot. - session.stop(); - drop(session); - let mut restarted = Session::start(&plugin_id, runtime, &arguments, &workspace); - restarted.await_event("snapshot.clear to be offered again", |event| { - event["type"] == "functionsChanged" - && event["result"]["functions"] - .as_array() - .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) - }); - let snapshot = restarted.call("read", "snapshot.read", json!({})); - assert_eq!(snapshot["result"]["value"]["generation"], 2, "{snapshot}"); - assert_eq!(snapshot["result"]["value"]["status"], "failed"); - assert_eq!( - restarted.call("clear", "snapshot.clear", json!({}))["result"]["value"], - Value::Null - ); - assert_eq!( - restarted.call("read-again", "snapshot.read", json!({}))["result"]["value"], - Value::Null - ); - restarted.stop(); - drop(restarted); - - // The backend wrote only to the host's state directory, never the package. - assert!( - listing(&package) == package_before, - "the plugin package changed at runtime" - ); - let state: Vec<_> = fs::read_dir(fixture.root.join("workspace-state")) - .expect("workspace state directory") - .map(|entry| entry.unwrap().file_name()) - .collect(); - assert!( - state.is_empty(), - "cleared state left files behind: {state:?}" - ); -} diff --git a/rust/lithe-core/tests/plugin_sdk_java.rs b/rust/lithe-core/tests/plugin_sdk_java.rs deleted file mode 100644 index 09744d3100..0000000000 --- a/rust/lithe-core/tests/plugin_sdk_java.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! End-to-end check of the official Java plugin SDK (`sdk/java`) against Rust -//! Core, with the scenario shared with the Node.js SDK in `plugin_sdk/mod.rs`. -//! -//! The sample is installed the way a plugin ships: `plugin.json` and the -//! shaded `backend/sample-build.jar` in one package. It runs only when -//! `LITHE_PLUGIN_SDK_JAVA` names a Java 8 or newer executable, -//! `LITHE_PLUGIN_SDK_JAVA_SAMPLE` the built sample JAR, and -//! `LITHE_PLUGIN_SDK_NODE` Node.js for the sample workspace's build tools; -//! `scripts/verify-rust-core-crates.sh` sets all three. - -mod plugin_sdk; - -#[test] -fn the_java_sdk_sample_backend_serves_build_requests_and_plugin_functions() { - let (Some(java), Some(jar)) = ( - std::env::var_os("LITHE_PLUGIN_SDK_JAVA"), - std::env::var_os("LITHE_PLUGIN_SDK_JAVA_SAMPLE"), - ) else { - eprintln!( - "skipped: set LITHE_PLUGIN_SDK_JAVA and LITHE_PLUGIN_SDK_JAVA_SAMPLE to run the Java SDK end-to-end check" - ); - return; - }; - let Some(node) = plugin_sdk::node_from_environment("the Java SDK end-to-end check") else { - return; - }; - let package = plugin_sdk::TemporaryPackage::new( - "java", - &[ - ( - "plugin.json", - plugin_sdk::repository_path("sdk/java/examples/sample-build/plugin.json"), - ), - ("backend/sample-build.jar", jar.into()), - ], - ); - plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { - tool: "java", - runtime: java.into_string().expect("UTF-8 Java path"), - package: package.root.clone(), - node, - }); -} diff --git a/rust/lithe-core/tests/plugin_sdk_node.rs b/rust/lithe-core/tests/plugin_sdk_node.rs deleted file mode 100644 index 15322fd13a..0000000000 --- a/rust/lithe-core/tests/plugin_sdk_node.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! End-to-end check of the official Node.js plugin SDK (`sdk/node`) against -//! Rust Core; the scenario is shared with the Java SDK in `plugin_sdk/mod.rs`. -//! It runs only when `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer -//! executable; `scripts/verify-rust-core-crates.sh` sets it. - -mod plugin_sdk; - -#[test] -fn the_node_sdk_sample_backend_serves_build_requests_and_plugin_functions() { - let Some(node) = plugin_sdk::node_from_environment("the Node.js SDK end-to-end check") else { - return; - }; - plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { - tool: "node", - runtime: node.clone(), - package: plugin_sdk::repository_path("sdk/node/examples/sample-build"), - node, - }); -} diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index 738a6d2c61..a375b31994 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -414,11 +414,6 @@ classify_path() { infra/docker/*) rust_database=true ;; - sdk/*) - # Plugin backend SDKs are verified in the Rust Core lane, which runs - # their unit tests and drives their samples through Core sessions. - rust_core=true - ;; *) # New or unclassified repository areas are validated conservatively # until their ownership is made explicit above. diff --git a/scripts/test-classify-ci-changes.sh b/scripts/test-classify-ci-changes.sh index 545ee97049..1469f69465 100755 --- a/scripts/test-classify-ci-changes.sh +++ b/scripts/test-classify-ci-changes.sh @@ -127,7 +127,6 @@ modify_readme() { printf '%s\n' '# Updated' > README.md; } modify_rust_comment() { printf '%s\n' '//! Updated test module.' 'pub fn value() -> u8 { 1 }' > rust/lithe-core/src/lib.rs; } modify_rust_code() { printf '%s\n' '//! Test module.' 'pub fn value() -> u8 { 2 }' > rust/lithe-core/src/lib.rs; } modify_rust_test() { printf '%s\n' '#[test]' 'fn value_is_two() { assert_eq!(2, 2); }' > rust/lithe-core/tests/value.rs; } -modify_plugin_sdk() { mkdir -p sdk/node/src && printf '%s\n' 'export const value = 2;' > sdk/node/src/index.mjs; } modify_rust_source_test() { printf '%s\n' '#[test]' 'fn source_test() { assert_eq!(2, 2); }' > rust/lithe-core/src/lsp/tests.rs; } modify_database_rust() { printf '%s\n' 'fn main() { println!("updated"); }' > rust/lithe-db-sidecar/src/main.rs; } modify_database_docker() { printf '%s\n' 'services:' ' mariadb: {}' > infra/docker/database-validation/compose.yaml; } @@ -232,9 +231,6 @@ assert_classification rust-code \ assert_classification rust-test \ "$(classification false false false true false false false false false false)" \ modify_rust_test -assert_classification plugin-sdk \ - "$(classification false false false true false false false false false false)" \ - modify_plugin_sdk assert_classification rust-source-test \ "$(classification false false false true false false false false false false)" \ modify_rust_source_test diff --git a/scripts/verify-rust-core-crates.sh b/scripts/verify-rust-core-crates.sh index 3a195347f7..7cf4806ed8 100755 --- a/scripts/verify-rust-core-crates.sh +++ b/scripts/verify-rust-core-crates.sh @@ -30,21 +30,4 @@ node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --report .artifacts/test-stability/ide-host-rust.json cargo build --locked --manifest-path rust/Cargo.toml -p lithe-ide-host --features mcp --bin lithe-mcp node --test scripts/test-ide-mcp.mjs -node --test --test-reporter=spec --test-reporter-destination=stdout \ - --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-sdk-node.xml \ - sdk/node/test/*.test.mjs -# The Java plugin SDK compiles for Java 8 with any newer JDK. When -# LITHE_PLUGIN_SDK_JAVA_RUNTIME names an older `java` (CI sets a Java 8 one), -# its tests and the sample backend run on that runtime instead of the build JDK. -for tool in java mvn; do - command -v "$tool" >/dev/null || { print -u2 "verify-rust-core-crates: $tool is required for sdk/java."; exit 1; } -done -java_runtime="${LITHE_PLUGIN_SDK_JAVA_RUNTIME:-$(command -v java)}" -mvn -B -ntp -f sdk/java/pom.xml verify -Djvm="$java_runtime" -cp sdk/java/plugin-sdk/target/surefire-reports/TEST-*.xml .artifacts/test-stability/ -# Opts the plugin SDK end-to-end tests in; they drive each sample backend -# through Core's real plugin session. -LITHE_PLUGIN_SDK_NODE="$(command -v node)" \ - LITHE_PLUGIN_SDK_JAVA="$java_runtime" \ - LITHE_PLUGIN_SDK_JAVA_SAMPLE="$ROOT_DIR/sdk/java/examples/sample-build/target/sample-build.jar" \ - cargo test --manifest-path rust/Cargo.toml -p lithe-core +cargo test --manifest-path rust/Cargo.toml -p lithe-core diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 91b749033f..e91c0e057b 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -151,15 +151,25 @@ fi # Declarative plugin contract v1. Semantics are executed by the Rust Core # tests against these files; this gate keeps the contract self-consistent. declarative_plugin_contract="shared/contracts/plugins/v1" +# Plugin developers read this contract from the public lithe-plugin-docs +# repository, which mirrors this directory byte for byte under contract/v1. +published_plugin_schema_base="https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1" for contract_file in "$declarative_plugin_contract"/**/*.json; do /usr/bin/ruby -rjson -e 'JSON.parse(File.read(ARGV.fetch(0)))' "$contract_file" done /usr/bin/ruby -rjson -e ' root = ARGV.fetch(0) + published_base = ARGV.fetch(1) schema = JSON.parse(File.read(File.join(root, "plugin-manifest.schema.json"))) - abort "plugin manifest schema ID mismatch" unless schema.fetch("$id").end_with?("/plugins/v1/plugin-manifest.schema.json") + abort "plugin manifest schema ID mismatch" unless schema.fetch("$id") == "#{published_base}/plugin-manifest.schema.json" abort "declarative manifests must use schemaVersion 2" unless schema.fetch("properties").fetch("schemaVersion").fetch("const") == 2 readme = File.read(File.join(root, "README.md"), encoding: "UTF-8") + # The mirror only holds this directory, so a relative link must resolve here. + readme.scan(/\]\(([^)\s]+)\)/).flatten.each do |target| + next if target.start_with?("#") || target.match?(%r{\A[a-z][a-z0-9+.-]*:}i) + path = File.expand_path(target.split("#").first, root) + abort "contract README link #{target} must be absolute or stay inside #{root}" unless path.start_with?("#{File.expand_path(root)}/") && File.exist?(path) + end documented = readme.scan(/`(plugin\.[a-zA-Z.]+)`/).flatten manifests = Dir[File.join(root, "fixtures", "*.plugin.json")] abort "declarative plugin fixtures are missing" if manifests.empty? @@ -220,7 +230,7 @@ done end abort "function protocol fixture contains a machine path" if JSON.generate(functions).match?(%r{/(?:Users|home|var/folders|private/var)/|[A-Za-z]:\\\\}) index_schema = JSON.parse(File.read(File.join(root, "plugin-index.schema.json"))) - abort "plugin index schema ID mismatch" unless index_schema.fetch("$id").end_with?("/plugins/v1/plugin-index.schema.json") + abort "plugin index schema ID mismatch" unless index_schema.fetch("$id") == "#{published_base}/plugin-index.schema.json" abort "plugin indexes must use schemaVersion 1" unless index_schema.fetch("properties").fetch("schemaVersion").fetch("const") == 1 marketplace = File.join(root, "fixtures", "marketplace") index = JSON.parse(File.read(File.join(marketplace, "index.json"))) @@ -246,7 +256,7 @@ done code = item["expectedCode"] abort "install review case #{item.fetch("name")} uses an undocumented code" if code && !documented.include?(code) end -' "$declarative_plugin_contract" +' "$declarative_plugin_contract" "$published_plugin_schema_base" /usr/bin/ruby -rjson -e ' data = JSON.parse(File.read(ARGV.fetch(0))) diff --git a/sdk/java/README.md b/sdk/java/README.md deleted file mode 100644 index 7231d39191..0000000000 --- a/sdk/java/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Lithe plugin backend SDK for Java - -`lithe-plugin-sdk-java` is the official way to write a plugin backend in Java: -the process a plugin starts through a `runtime` launch -(`{ "kind": "runtime", "tool": "java", "minimumVersion": "8", "entry": "backend/app.jar" }`). -It is the Java twin of the [Node.js SDK](../node/README.md). Both implement the -same two protocols from -[plugin contract v1](../../shared/contracts/plugins/v1/README.md) with the -same rules and error mapping: - -- **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` - requests, for a build system whose `model` or `dependencies` source is - `provider`. -- **`litheFunctions` 1**: plugin functions, which are plugin-private actions - that Lithe invokes for the same plugin. They support progress, - cancellation, and registration at runtime. - -The SDK requires Java 8 or newer and has no runtime dependencies. JSON values -are plain `Map`, `List`, `String`, `Long`, `Double`, -`Boolean`, and `null` (see `Json`). - -## A minimal backend - -```java -Backend backend = Backend.create("example-backend", "1.0.0"); - -backend.onModel((params, context) -> { - Map project = new LinkedHashMap<>(); - project.put("name", "app"); - project.put("path", Json.string(params, "root")); - Map model = new LinkedHashMap<>(); - model.put("projects", Collections.singletonList(project)); - return model; -}); - -backend.function("snapshot.refresh", (params, context) -> { - context.progress("Refreshing", 10.0); - Processes.Result result = context.run(Arrays.asList("example-tool", "refresh")); - if (result.exitCode() != 0) { - throw new PluginError("example.refreshFailed", "The refresh failed."); - } - return Collections.singletonMap("refreshedAt", System.currentTimeMillis()); -}); - -backend.listen(); -``` - -[`examples/sample-build`](examples/sample-build) is a complete plugin. It has -a build provider, functions, persistent state, a function registered at -runtime, and cancellation. Rust Core's -`rust/lithe-core/tests/plugin_sdk_java.rs` drives it through a real Core -session, and runs the same scenario that `plugin_sdk_node.rs` runs for the -Node.js sample. - -## Getting the SDK - -Each release is tagged `sdk-java-v`. A release ships -`lithe-plugin-sdk-java-.jar` and a `.sha256` file holding the JAR's -SHA-256. The SDK is not published to a Maven repository. Use it like this: - -1. Download the JAR during your plugin's build, never at runtime. -2. Check the JAR against the SHA-256 you pinned in your repository. -3. Shade it into your backend JAR, as the sample's `pom.xml` does. - -A plugin package is installed read-only and must run offline, so the backend -JAR it starts must hold everything it loads. - -## API - -| API | Purpose | -| --- | --- | -| `Backend.create(name, version[, capabilities])` | Creates the backend. `capabilities` are standard LSP server capabilities. `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | -| `onModel(handler)`, `onDependencies(handler)` | Implement the typed build requests. The SDK stamps `format` and `version`. Core validates the document. `litheBuild` is answered only when a handler exists. | -| `function(id, handler)` | Declares a function offered in `initialize`. | -| `registerFunction(id, handler)`, `unregisterFunction(id)` | Add or withdraw a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list. Later calls send `lithe/function/register` or `lithe/function/unregister`, in order. Both return a `CompletableFuture`. Withdrawing a function revokes its running calls. | -| `onInitialize(hook)`, `onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | -| `onRequest(method, handler)`, `onNotification(method, handler)` | Ordinary LSP methods. Lifecycle and `lithe/*` methods are reserved. | -| `sendRequest`, `sendNotification` | Ordinary LSP messages to the host. `lithe/*` methods are reserved. | -| `log(message[, error])` | Writes to stderr, which the host keeps in its log. | -| `listen()` | Serves stdio until `exit` or the end of input, then exits the JVM with the protocol's exit code. `listen(in, out, err)` serves other streams and returns the code instead. | -| `PluginError(code, message)` | A failure meant for your own UI. | -| `Processes.run(command[, options])` | Runs a process without a shell. Its stdin is closed. `Options` set the directory, environment, output limits, a cancellation, and output callbacks. | -| `StateStore.open(directory)` | A small atomic JSON store in a host-provided directory. | - -Handlers run on their own threads. Every handler receives a `CallContext`: - -| Member | Meaning | -| --- | --- | -| `cancellation()`, `isCancelled()` | Cancelled when any of these happens: Lithe cancels the call, its deadline passes, the function is withdrawn, the session ends, or the handler finishes. Finishing the handler stops the work it left running. | -| `throwIfCancelled()` | Throws `CancelledException` once the call is cancelled. | -| `run(command[, options])` | `Processes.run` bound to the call: cancelling or ending the call stops the process. | -| `log(message)` | Same as `Backend.log`. | -| `functionId()`, `operationId()` | Functions only. `operationId` is opaque; never derive anything from it. | -| `progress(message, percentage)` | Functions only. Either argument may be `null`. The message is cut to 1024 bytes and the percentage is clamped to 0–100. Reports sent after the call ends are dropped. | - -Results and errors map exactly as in the Node.js SDK: - -- A returned value becomes `result.value`. -- `PluginError` becomes `plugin.function.failed` with your message and - `pluginErrorCode`. -- Any other exception is reported with a generic message, and its stack trace - goes to stderr. -- `CancelledException` ends the call as cancelled. -- A result larger than 1 MiB of JSON fails like an unexpected error. - -## Rules that keep Lithe in charge - -These are the [Node.js SDK's rules](../node/README.md#rules-that-keep-lithe-in-charge). -In Java: - -1. **Domain capabilities stay typed.** Build models and dependency trees go - through `onModel` and `onDependencies`, never through a function. -2. **No calls back into Lithe's dispatch.** The SDK refuses to send any - `lithe/*` method. -3. **stdout belongs to the protocol.** `listen()` keeps the real stdout for - protocol frames and points `System.out` at stderr. Never write to - `FileDescriptor.out`. -4. **The package is read-only.** Keep state under the directories Lithe - passes through launch templates (`${stateDir}`, `${workspaceStateDir}`). - `StateStore.open` refuses a directory inside the plugin package. It finds - the package by walking up from the SDK's own JAR to `plugin.json`. -5. **Work ends with its call.** Start processes with `context.run`, and check - `isCancelled()` in long loops. On cancellation the SDK first asks the - process to stop, then stops it forcibly after a grace period. - - **macOS and Linux, every Java version:** each tool leads its own process - group, as in the Node SDK. The SDK starts it through the system `perl`, - which calls `setpgrp` and then becomes the tool. A stop reaches the whole - group, and when the tool exits on its own, whatever it left running in - its group is killed. - - **Windows, or no `perl`:** on Java 9 or newer the SDK stops the - descendants still attached to the tool; Java 8 stops only the direct - child. - - A descendant that leaves the group (`setsid`) or that the fallback cannot - reach is ended by Lithe's process scope together with the backend. - A cancellation listener that throws is logged and does not keep the other - listeners or the reply from running. - - On `shutdown`, `exit`, or the end of input the SDK ends every process it - started. `Processes.run` keeps Lithe's process-scope markers even when - `Options.environment` replaces the environment. -6. **Errors stay presentable.** Throw `PluginError` for failures the user - should see; every other exception is reported generically. - -## Testing - -```bash -mvn -B -f sdk/java/pom.xml verify -``` - -This builds the SDK and the sample for Java 8 with any newer JDK, then runs -the JUnit tests. Add `-Djvm=/path/to/java8/bin/java` to run the tests on a -Java 8 runtime, as CI does. diff --git a/sdk/java/examples/sample-build/plugin.json b/sdk/java/examples/sample-build/plugin.json deleted file mode 100644 index 8e93ebc10a..0000000000 --- a/sdk/java/examples/sample-build/plugin.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "schemaVersion": 2, - "id": "org.example.sample-build-java", - "displayName": "Sample Build (Java)", - "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { - "buildProviders": 1, - "buildSystems": 1, - "serverProcess": 1 - }, - "vendor": { - "id": "org.example", - "displayName": "Example", - "signatureRequirement": "publisherPackage" - }, - "permissions": [ - "languageServer.launch", - "process.build" - ], - "activationEvents": [ - "onBuildSystem:sample" - ], - "languages": [], - "buildSystems": [ - { - "id": "sample", - "displayName": "Sample", - "detection": { - "markers": [ - "lithe-sample.json" - ], - "nesting": "outermost" - }, - "invocation": { - "executables": [ - { - "kind": "command", - "command": "java" - } - ] - }, - "provider": { - "launch": { - "kind": "runtime", - "tool": "java", - "minimumVersion": "8", - "entry": "backend/sample-build.jar" - }, - "arguments": [ - "--state", - "${workspaceStateDir}" - ], - "executesProjectCode": true, - "mayUseNetwork": false - }, - "model": { - "source": "provider" - }, - "tasks": [ - { - "id": "build", - "displayName": "Build", - "group": "build", - "scopes": [ - "workspace" - ], - "arguments": [ - "-version" - ] - } - ], - "dependencies": { - "source": "provider", - "scopes": [ - "workspace", - "project" - ] - }, - "sync": { - "watch": [ - "lithe-sample.json" - ] - } - } - ] -} diff --git a/sdk/java/examples/sample-build/pom.xml b/sdk/java/examples/sample-build/pom.xml deleted file mode 100644 index cacbb00d09..0000000000 --- a/sdk/java/examples/sample-build/pom.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - 4.0.0 - - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - ../../pom.xml - - - sample-build - Lithe sample build provider (Java) - - - - io.github.lithe-idea - lithe-plugin-sdk-java - ${project.version} - - - - - sample-build - - - - org.apache.maven.plugins - maven-shade-plugin - - - package - - shade - - - false - - - org.example.samplebuild.SampleBuild - - - - - - - - - diff --git a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java b/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java deleted file mode 100644 index 492f9e210d..0000000000 --- a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java +++ /dev/null @@ -1,241 +0,0 @@ -package org.example.samplebuild; - -import io.github.litheidea.plugin.sdk.Backend; -import io.github.litheidea.plugin.sdk.CallContext; -import io.github.litheidea.plugin.sdk.Json; -import io.github.litheidea.plugin.sdk.PluginError; -import io.github.litheidea.plugin.sdk.Processes; -import io.github.litheidea.plugin.sdk.StateStore; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Sample Lithe plugin backend built on the official Java SDK; the Java twin of - * {@code sdk/node/examples/sample-build}. - * - *

It is the build provider of the fictional "sample" build system, whose - * workspace file {@code lithe-sample.json} lists projects: - * - *

- * { "projects": [{ "name": "app", "path": "app",
- *                  "build": ["/usr/bin/make"],
- *                  "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] }] }
- * 
- * - *

It shows the three things a backend does: - *

    - *
  • answers Lithe's typed build requests ({@code lithe/build/model}, - * {@code lithe/build/dependencies}), whose results Lithe itself understands;
  • - *
  • offers plugin functions for its own UI: {@code workspace.build} runs a - * multi-step build (prepare, run each project's tool, record a snapshot), - * and {@code snapshot.read} returns the last snapshot, also after a restart;
  • - *
  • registers {@code snapshot.clear} at runtime once there is a snapshot to clear.
  • - *
- * - *

A packaged plugin shades the SDK into its own JAR, as this module's POM does. - */ -public final class SampleBuild { - private final Backend backend = Backend.create("sample-build", "0.1.0"); - private final String stateDirectory; - private volatile Path workspaceRoot; - private volatile StateStore store; - - private SampleBuild(String stateDirectory) { - this.stateDirectory = stateDirectory; - } - - public static void main(String[] arguments) { - new SampleBuild(argument(arguments, "--state")).serve(); - } - - private void serve() { - backend.onInitialize((params, negotiated) -> { - // `Paths.get(URI)` accepts the trailing slash a directory URI may carry. - workspaceRoot = Paths.get(URI.create((String) params.get("rootUri"))).toAbsolutePath().normalize(); - // `--state ${workspaceStateDir}` in plugin.json: the host's writable - // directory for this workspace. The package itself is read-only. - store = StateStore.open(Paths.get(stateDirectory)); - if (store.read("snapshot", null) != null) { - offerClear(); - } - }); - - backend.onModel((params, context) -> { - List projects = new ArrayList<>(); - for (Map project : readConfig(Json.string(params, "root"))) { - projects.add(object("name", project.get("name"), "path", project.get("path"))); - } - return object("projects", projects); - }); - - backend.onDependencies((params, context) -> { - Object selected = params.get("project"); - String path = selected instanceof Map ? Json.string(Json.object(selected), "path") : null; - List dependencies = new ArrayList<>(); - for (Map project : readConfig(Json.string(params, "root"))) { - if (path != null && !path.equals(project.get("path"))) { - continue; - } - Object declared = project.get("dependencies"); - if (declared instanceof List) { - for (Object entry : (List) declared) { - Map dependency = Json.object(entry); - dependencies.add(object("name", dependency.get("name"), "version", dependency.get("version"), - "children", new ArrayList<>())); - } - } - } - return object("dependencies", dependencies); - }); - - backend.function("workspace.build", this::build); - backend.function("snapshot.read", (params, context) -> store.read("snapshot", null)); - backend.listen(); - } - - private Object build(Map params, CallContext context) throws IOException { - // Step 1: prepare. Read the workspace file and resolve the selection. - context.progress("Preparing", 0.0); - List> projects = selectProjects(readConfig("."), params.get("projects")); - - // Step 2: run each project's tool. `context.run` stops the tool when - // Lithe cancels the call, so a cancelled build leaves nothing behind. - List results = new ArrayList<>(); - for (int index = 0; index < projects.size(); index++) { - Map project = projects.get(index); - String name = (String) project.get("name"); - context.progress("Building " + name, 100.0 * index / projects.size()); - List command = new ArrayList<>(); - for (Object part : (List) project.get("build")) { - command.add((String) part); - } - Processes.Result result = context.run(command, new Processes.Options() - .directory(inside(workspaceRoot, (String) project.get("path"))) - .onStdout(text -> context.log("[" + name + "] " + text.trim()))); - results.add(object("name", name, "exitCode", result.exitCode())); - if (result.exitCode() != 0) { - record(results, "failed"); - throw new PluginError("sample.buildFailed", name + " failed with exit code " + result.exitCode() + "."); - } - } - - // Step 3: record the outcome, so `snapshot.read` returns it after a restart. - Object snapshot = record(results, "succeeded"); - context.progress("Done", 100.0); - return snapshot; - } - - /** - * Reads the latest snapshot when the build ends, not when it starts: two - * overlapping builds then get consecutive generations instead of the same - * one. Handlers run on their own threads, so the read and the write hold - * one lock. - */ - private synchronized Object record(List projects, String status) throws IOException { - Object previous = store.read("snapshot", null); - long generation = previous instanceof Map - ? ((Number) Json.object(previous).get("generation")).longValue() + 1 - : 1; - Map snapshot = object("generation", generation, "status", status, "projects", projects); - store.write("snapshot", snapshot); - offerClear(); - return snapshot; - } - - private void offerClear() { - if (backend.functionIds().contains("snapshot.clear")) { - return; - } - backend.registerFunction("snapshot.clear", (params, context) -> { - store.delete("snapshot"); - return null; - }).whenComplete((ignored, error) -> { - if (error != null) { - backend.log("snapshot.clear stays unavailable: " + error.getMessage()); - } - }); - } - - /** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ - private static List> selectProjects(List> projects, Object selected) { - if (selected == null) { - return projects; - } - if (!(selected instanceof List) || ((List) selected).isEmpty() - || !((List) selected).stream().allMatch(name -> name instanceof String)) { - throw new PluginError("sample.selection", "\"projects\" must be a non-empty list of project names."); - } - List unknown = new ArrayList<>(); - for (Object name : (List) selected) { - if (projects.stream().noneMatch(project -> name.equals(project.get("name")))) { - unknown.add((String) name); - } - } - if (!unknown.isEmpty()) { - throw new PluginError("sample.unknownProject", "Unknown project: " + String.join(", ", unknown) + "."); - } - List> chosen = new ArrayList<>(); - for (Map project : projects) { - if (((List) selected).contains(project.get("name"))) { - chosen.add(project); - } - } - return chosen; - } - - private List> readConfig(String root) { - Object config; - try { - Path file = inside(workspaceRoot, root == null ? "." : root).resolve("lithe-sample.json"); - config = Json.parse(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); - } catch (IOException | RuntimeException error) { - if (error instanceof PluginError) { - throw (PluginError) error; - } - throw new PluginError("sample.config", "lithe-sample.json is missing or is not valid JSON."); - } - Object projects = config instanceof Map ? ((Map) config).get("projects") : null; - if (!(projects instanceof List)) { - throw new PluginError("sample.config", "lithe-sample.json needs a \"projects\" list."); - } - List> result = new ArrayList<>(); - for (Object project : (List) projects) { - result.add(Json.object(project)); - } - return result; - } - - /** {@code relative} resolved inside {@code base}; workspace files never name paths outside it. */ - private static Path inside(Path base, String relative) { - Path path = base.resolve(relative).normalize(); - if (Paths.get(relative).isAbsolute() || !path.startsWith(base)) { - throw new PluginError("sample.path", relative + " is outside the workspace."); - } - return path; - } - - private static Map object(Object... entries) { - Map map = new LinkedHashMap<>(); - for (int index = 0; index < entries.length; index += 2) { - map.put((String) entries[index], entries[index + 1]); - } - return map; - } - - private static String argument(String[] arguments, String name) { - for (int index = 0; index + 1 < arguments.length; index++) { - if (arguments[index].equals(name)) { - return arguments[index + 1]; - } - } - return null; - } -} diff --git a/sdk/java/plugin-sdk/pom.xml b/sdk/java/plugin-sdk/pom.xml deleted file mode 100644 index 6ad56d196b..0000000000 --- a/sdk/java/plugin-sdk/pom.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - 4.0.0 - - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - - - lithe-plugin-sdk-java - Lithe plugin SDK for Java - Plugin functions and build providers over the LSP base protocol, for Lithe plugin backends. - - - - org.junit.jupiter - junit-jupiter - - - - - - - org.apache.maven.plugins - maven-jar-plugin - - - false - - lithe-plugin-sdk-java - ${project.version} - - - - - - - diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java deleted file mode 100644 index ad32a637e1..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ /dev/null @@ -1,1062 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.FileDescriptor; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; - -/** - * The plugin backend session: one process the host starts from the plugin's - * manifest and talks to over stdio with the LSP base protocol. - * - *

The SDK owns everything Lithe manages uniformly -- the lifecycle, protocol - * negotiation, cancellation, error codes, and the {@code lithe/*} namespace -- - * and leaves the plugin free to decide what its handlers do. Declare handlers, - * then call {@link #listen()}. Handlers run on their own threads, so several - * calls can be in flight at once; shared plugin state needs its own locking. - * - *

Contract: {@code shared/contracts/plugins/v1/README.md} ("Build providers" - * and "Plugin functions"). Behaviour and error codes match the Node.js SDK. - */ -public final class Backend { - /** Answers a typed build request or a plugin function. */ - @FunctionalInterface - public interface Handler { - Object handle(Map params, CallContext context) throws Exception; - } - - /** Answers a standard LSP request, for a backend that is also a language server. */ - @FunctionalInterface - public interface RequestHandler { - Object handle(Object params, CallContext context) throws Exception; - } - - /** Handles a standard LSP notification. */ - @FunctionalInterface - public interface NotificationHandler { - void handle(Object params) throws Exception; - } - - /** Runs before the {@code initialize} answer. */ - @FunctionalInterface - public interface InitializeHook { - void run(Map params, Negotiated negotiated) throws Exception; - } - - /** Runs on {@code shutdown}, after running calls were cancelled. */ - @FunctionalInterface - public interface ShutdownHook { - void run() throws Exception; - } - - /** Protocols the host agreed to in {@code initialize}. */ - public static final class Negotiated { - static final Negotiated NONE = new Negotiated(false, false); - - private final boolean functions; - private final boolean build; - - Negotiated(boolean functions, boolean build) { - this.functions = functions; - this.build = build; - } - - /** Plugin functions, {@code litheFunctions} version 1. */ - public boolean functions() { - return functions; - } - - /** Typed build requests, {@code litheBuild} version 1. */ - public boolean build() { - return build; - } - } - - private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED } - - /** Canonical result format of each typed build request. */ - private static final Map BUILD_FORMATS; - - static { - Map formats = new HashMap<>(); - formats.put(Protocol.BUILD_MODEL, "litheBuildModel"); - formats.put(Protocol.BUILD_DEPENDENCIES, "litheDependencies"); - BUILD_FORMATS = Collections.unmodifiableMap(formats); - } - - private final String name; - private final String version; - private final Map capabilities; - private final Object lock = new Object(); - private final Object writeLock = new Object(); - private final Map functions = new HashMap<>(); - private final Map buildHandlers = new HashMap<>(); - private final Map requestHandlers = new HashMap<>(); - private final Map notificationHandlers = new HashMap<>(); - private final List initializeHooks = new ArrayList<>(); - private final List shutdownHooks = new ArrayList<>(); - /** Host requests being handled, by JSON-RPC ID. */ - private final Map incoming = new HashMap<>(); - /** Requests this backend sent to the host, by JSON-RPC ID. */ - private final Map outgoing = new HashMap<>(); - private final AtomicInteger nextRequestId = new AtomicInteger(1); - private final ExecutorService executor = Executors.newCachedThreadPool(new ThreadNamer()); - /** Serializes register/unregister so the host sees them in call order. */ - private CompletableFuture registrations = CompletableFuture.completedFuture(null); - private volatile Phase phase = Phase.IDLE; - private volatile Negotiated negotiated = Negotiated.NONE; - private volatile Map initializeParams; - private volatile OutputStream output; - private volatile PrintStream errorOutput = System.err; - private int exitCode = 1; - - private Backend(String name, String version, Map capabilities) { - Object experimental = capabilities.get("experimental"); - if (experimental instanceof Map - && (((Map) experimental).containsKey("litheBuild") - || ((Map) experimental).containsKey("litheFunctions"))) { - throw new PluginSdkError("sdk.capabilities.reserved", - "litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead."); - } - this.name = name == null ? "lithe-plugin-backend" : name; - this.version = version; - this.capabilities = new LinkedHashMap<>(capabilities); - } - - /** - * Creates a backend. - * - * @param name reported as {@code serverInfo.name} and used as the log prefix - * @param version reported as {@code serverInfo.version}; may be null - */ - public static Backend create(String name, String version) { - return new Backend(name, version, Collections.emptyMap()); - } - - /** - * Creates a backend that also declares standard LSP server capabilities. - * {@code experimental.litheBuild} and {@code experimental.litheFunctions} - * are negotiated by the SDK and cannot be set here. - */ - public static Backend create(String name, String version, Map capabilities) { - return new Backend(name, version, capabilities == null ? Collections.emptyMap() : capabilities); - } - - /** Protocols the host agreed to in {@code initialize}; both false before it. */ - public Negotiated negotiated() { - return negotiated; - } - - /** The host's {@code initialize} params, or null before it arrives. */ - public Map initializeParams() { - return initializeParams; - } - - /** IDs of the functions this backend currently serves, sorted. */ - public List functionIds() { - synchronized (lock) { - List ids = new ArrayList<>(functions.keySet()); - Collections.sort(ids); - return ids; - } - } - - /** Answers {@code lithe/build/model} with a {@code litheBuildModel} document. */ - public Backend onModel(Handler handler) { - return declareBuild(Protocol.BUILD_MODEL, handler); - } - - /** Answers {@code lithe/build/dependencies} with a {@code litheDependencies} document. */ - public Backend onDependencies(Handler handler) { - return declareBuild(Protocol.BUILD_DEPENDENCIES, handler); - } - - /** - * Declares a plugin function offered from the start, in the {@code initialize} - * answer. To add one later, use {@link #registerFunction}. - * - * @param id dot-separated lowerCamelCase segments, such as {@code snapshot.refresh} - */ - public Backend function(String id, Handler handler) { - synchronized (lock) { - if (!beforeAnswer()) { - throw new PluginSdkError("sdk.function.lateDeclaration", - "After initialize, add functions with registerFunction()."); - } - checkNewFunction(id, handler); - functions.put(id, handler); - } - return this; - } - - /** Handles a standard LSP request, for a backend that is also a language server. */ - public Backend onRequest(String method, RequestHandler handler) { - checkDeclared(method, handler); - synchronized (lock) { - requestHandlers.put(method, handler); - } - return this; - } - - /** Handles a standard LSP notification. */ - public Backend onNotification(String method, NotificationHandler handler) { - checkDeclared(method, handler); - synchronized (lock) { - notificationHandlers.put(method, handler); - } - return this; - } - - /** Runs before the {@code initialize} answer, with the host's params and the negotiation. */ - public Backend onInitialize(InitializeHook hook) { - synchronized (lock) { - initializeHooks.add(hook); - } - return this; - } - - /** Runs on {@code shutdown}, after running calls were cancelled. */ - public Backend onShutdown(ShutdownHook hook) { - synchronized (lock) { - shutdownHooks.add(hook); - } - return this; - } - - /** - * Adds a function. Before the {@code initialize} answer (including from an - * {@link #onInitialize} hook) it joins the initial list; afterwards the host - * must accept it first. The future completes once the function is served. - */ - public CompletableFuture registerFunction(String id, Handler handler) { - synchronized (lock) { - if (beforeAnswer()) { - try { - checkNewFunction(id, handler); - functions.put(id, handler); - return CompletableFuture.completedFuture(null); - } catch (RuntimeException error) { - return failed(error); - } - } - } - return queueRegistration(() -> { - synchronized (lock) { - requireFunctions(); - checkNewFunction(id, handler); - // Installed before the request: the host may invoke it as soon as - // its registry changed, before this backend reads the reply. - functions.put(id, handler); - } - Map entry = new LinkedHashMap<>(); - entry.put("id", id); - Map params = new LinkedHashMap<>(); - params.put("functions", Collections.singletonList(entry)); - return request(Protocol.FUNCTION_REGISTER, params).handle((result, error) -> { - if (error != null) { - synchronized (lock) { - functions.remove(id); - } - throw new CompletionException(unwrap(error)); - } - return null; - }); - }); - } - - /** Withdraws a function. The host ends its running calls as revoked. */ - public CompletableFuture unregisterFunction(String id) { - synchronized (lock) { - if (beforeAnswer()) { - if (functions.remove(id) == null) { - return failed(unknownFunction(id)); - } - return CompletableFuture.completedFuture(null); - } - } - return queueRegistration(() -> { - synchronized (lock) { - requireFunctions(); - if (!functions.containsKey(id)) { - throw unknownFunction(id); - } - } - Map params = new LinkedHashMap<>(); - params.put("ids", Collections.singletonList(id)); - return request(Protocol.FUNCTION_UNREGISTER, params).thenApply(result -> { - List running = new ArrayList<>(); - synchronized (lock) { - functions.remove(id); - for (Call call : incoming.values()) { - if (id.equals(call.functionId)) { - running.add(call); - } - } - } - for (Call call : running) { - call.cancellation.cancel("The function was withdrawn."); - } - return null; - }); - }); - } - - /** Sends a standard LSP request to the host. {@code lithe/*} methods are reserved. */ - public CompletableFuture sendRequest(String method, Object params) { - checkOutgoing(method); - return request(method, params); - } - - /** Sends a standard LSP notification to the host. {@code lithe/*} methods are reserved. */ - public void sendNotification(String method, Object params) { - checkOutgoing(method); - Map message = message(null, method); - if (params != null) { - message.put("params", params); - } - write(message); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - public void log(String message) { - errorOutput.println("[" + name + "] " + message); - } - - /** Writes a diagnostic line and the failure's stack trace to stderr. */ - public void log(String message, Throwable error) { - PrintStream stream = errorOutput; - synchronized (stream) { - stream.println("[" + name + "] " + message + " " + error); - error.printStackTrace(stream); - } - } - - /** - * Serves the host over stdio until it sends {@code exit} or closes stdin, - * then exits the process with the session's exit code: 0 after a - * {@code shutdown}, 1 otherwise. {@code System.out} is redirected to stderr - * first, because stdout carries the protocol and a stray line there would - * end the session. - */ - public void listen() { - OutputStream protocol = new FileOutputStream(FileDescriptor.out); - System.setOut(System.err); - int code = listen(System.in, protocol, System.err); - System.exit(code); - } - - /** - * Serves the host over the given streams and returns the exit code once the - * session ended, without exiting the process. Blocks the calling thread. - */ - public int listen(InputStream input, OutputStream output, PrintStream errorOutput) { - synchronized (lock) { - if (phase != Phase.IDLE) { - throw new PluginSdkError("sdk.listen.repeated", "A backend listens once."); - } - phase = Phase.LISTENING; - this.output = output; - this.errorOutput = errorOutput; - } - Framing.Decoder decoder = new Framing.Decoder(); - byte[] chunk = new byte[64 * 1024]; - try { - while (phase != Phase.EXITED) { - int count = input.read(chunk); - if (count < 0) { - finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); - break; - } - List messages; - try { - messages = decoder.push(chunk, 0, count); - } catch (Framing.FramingException error) { - log("Ending the session: " + error.getMessage()); - finish(1); - break; - } - for (Object message : messages) { - if (phase == Phase.EXITED) { - break; - } - dispatch(message); - } - } - } catch (IOException error) { - finish(1); - } - synchronized (lock) { - return exitCode; - } - } - - private void dispatch(Object message) { - if (message == Framing.MALFORMED) { - respond(null, null, error(Protocol.PARSE_ERROR, "The message is not valid JSON.")); - return; - } - // Valid JSON that is not a message object, such as `null` or a batch. - if (!(message instanceof Map)) { - respond(null, null, error(Protocol.INVALID_REQUEST, "The message is not a JSON-RPC object.")); - return; - } - Map object = Json.object(message); - Object id = object.get("id"); - Object method = object.get("method"); - if (!(method instanceof String)) { - if (id != null) { - settleOutgoing(object); - } - return; - } - if (id != null) { - handleRequest(id, (String) method, object.get("params")); - } else { - handleNotification((String) method, object.get("params")); - } - } - - private void handleRequest(Object id, String method, Object params) { - if (method.equals(Protocol.INITIALIZE)) { - initialize(id, params instanceof Map ? Json.object(params) : new LinkedHashMap<>()); - return; - } - if (method.equals(Protocol.SHUTDOWN)) { - shutdown(id); - return; - } - Phase current = phase; - if (current == Phase.LISTENING || current == Phase.INITIALIZING) { - respond(id, null, error(Protocol.SERVER_NOT_INITIALIZED, "The backend is not initialized.")); - return; - } - if (current != Phase.READY) { - respond(id, null, error(Protocol.INVALID_REQUEST, "The backend is shutting down.")); - return; - } - if (method.equals(Protocol.FUNCTION_INVOKE)) { - invokeFunction(id, params); - return; - } - Handler build; - RequestHandler handler; - synchronized (lock) { - build = negotiated.build() ? buildHandlers.get(method) : null; - handler = requestHandlers.get(method); - } - if (build != null) { - if (params != null && !(params instanceof Map)) { - respond(id, null, error(Protocol.INVALID_PARAMS, method + " params must be an object.")); - return; - } - Map arguments = Json.object(params); - run(id, null, null, context -> build.handle(arguments, context), result -> buildResult(method, result), null); - return; - } - if (handler != null) { - run(id, null, null, context -> handler.handle(params, context), result -> result, null); - return; - } - respond(id, null, error(Protocol.METHOD_NOT_FOUND, "Unsupported method: " + method)); - } - - private void handleNotification(String method, Object params) { - if (method.equals(Protocol.EXIT)) { - finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); - return; - } - if (method.equals(Protocol.CANCEL_REQUEST)) { - Object id = params instanceof Map ? ((Map) params).get("id") : null; - Call call; - synchronized (lock) { - call = incoming.get(requestKey(id)); - } - if (call != null) { - call.cancellation.cancel("The request was cancelled."); - } - return; - } - if (phase != Phase.READY || method.equals(Protocol.INITIALIZED)) { - return; - } - NotificationHandler handler; - synchronized (lock) { - handler = notificationHandlers.get(method); - } - if (handler == null) { - return; - } - executor.execute(() -> { - try { - handler.handle(params); - } catch (Throwable error) { - log("Notification " + method + " failed:", error); - } - }); - } - - private void initialize(Object id, Map params) { - Negotiated agreed; - List hooks; - synchronized (lock) { - if (phase != Phase.LISTENING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "initialize was already received.")); - return; - } - phase = Phase.INITIALIZING; - initializeParams = params; - Map offered = experimental(params.get("capabilities")); - agreed = new Negotiated( - offers(offered.get("litheFunctions"), Protocol.FUNCTIONS_VERSION), - !buildHandlers.isEmpty() && offers(offered.get("litheBuild"), Protocol.BUILD_VERSION)); - negotiated = agreed; - hooks = new ArrayList<>(initializeHooks); - } - executor.execute(() -> { - try { - for (InitializeHook hook : hooks) { - hook.run(params, agreed); - } - } catch (Throwable failure) { - log("initialize failed:", failure); - synchronized (lock) { - if (phase == Phase.INITIALIZING) { - phase = Phase.LISTENING; - } - } - respond(id, null, toResponseError(failure, null)); - return; - } - synchronized (lock) { - // A shutdown or exit that arrived while a hook ran ended the - // session; finishing initialize must not reopen it. - if (phase != Phase.INITIALIZING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "The session ended during initialize.")); - return; - } - if (!agreed.functions() && !functions.isEmpty()) { - log("The host did not offer litheFunctions version 1; functions stay unavailable."); - } - Map experimental = new LinkedHashMap<>(experimental(capabilities)); - if (agreed.build()) { - experimental.put("litheBuild", singleton("version", Protocol.BUILD_VERSION)); - } - if (agreed.functions()) { - List offered = new ArrayList<>(); - for (String functionId : functionIds()) { - offered.add(singleton("id", functionId)); - } - Map entry = singleton("version", Protocol.FUNCTIONS_VERSION); - entry.put("functions", offered); - experimental.put("litheFunctions", entry); - } - Map answered = new LinkedHashMap<>(capabilities); - answered.put("experimental", experimental); - Map serverInfo = singleton("name", name); - if (version != null) { - serverInfo.put("version", version); - } - Map result = singleton("capabilities", answered); - result.put("serverInfo", serverInfo); - phase = Phase.READY; - respond(id, result, null); - } - }); - } - - private void shutdown(Object id) { - List hooks; - synchronized (lock) { - if (phase != Phase.READY && phase != Phase.LISTENING && phase != Phase.INITIALIZING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "shutdown was already received.")); - return; - } - phase = Phase.SHUTTING_DOWN; - hooks = new ArrayList<>(shutdownHooks); - } - cancelAll("The backend is shutting down."); - executor.execute(() -> { - for (ShutdownHook hook : hooks) { - try { - hook.run(); - } catch (Throwable error) { - log("A shutdown hook failed:", error); - } - } - Processes.killAll(); - respond(id, null, null); - }); - } - - private void invokeFunction(Object id, Object raw) { - Map invocation = raw instanceof Map ? Json.object(raw) : new LinkedHashMap<>(); - Object functionId = invocation.get("functionID"); - Object operationId = invocation.get("operationID"); - Object params = invocation.get("params"); - Handler handler; - synchronized (lock) { - handler = negotiated.functions() && functionId instanceof String ? functions.get(functionId) : null; - } - if (handler == null) { - respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " is not registered.")); - return; - } - if (params != null && !(params instanceof Map)) { - respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " params must be an object.")); - return; - } - String function = (String) functionId; - String operation = operationId instanceof String ? (String) operationId : null; - Map arguments = Json.object(params); - AtomicBoolean reporting = new AtomicBoolean(true); - CallContext.ProgressSink progress = (message, percentage) -> { - if (!reporting.get()) { - return; - } - Map report = new LinkedHashMap<>(); - report.put("operationID", operationId); - if (message != null) { - report.put("message", Protocol.boundedText(message)); - } - if (percentage != null && !percentage.isNaN() && !percentage.isInfinite()) { - report.put("percentage", Math.min(100L, Math.max(0L, Math.round(percentage)))); - } - Map notification = message(null, Protocol.FUNCTION_PROGRESS); - notification.put("params", report); - write(notification); - }; - run(id, function, operation, context -> handler.handle(arguments, context), Backend::functionResult, - () -> reporting.set(false), progress); - } - - private interface Invocation { - Object invoke(CallContext context) throws Exception; - } - - private interface Shape { - Object apply(Object result); - } - - private void run(Object id, String functionId, String operationId, Invocation invoke, Shape shape, Runnable onSettled) { - run(id, functionId, operationId, invoke, shape, onSettled, null); - } - - /** Runs one handler with its own cancellation and answers exactly once. */ - private void run(Object id, String functionId, String operationId, Invocation invoke, Shape shape, - Runnable onSettled, CallContext.ProgressSink progress) { - Cancellation cancellation = new Cancellation(); - String key = requestKey(id); - synchronized (lock) { - incoming.put(key, new Call(cancellation, functionId)); - } - CallContext context = new CallContext(cancellation, this, functionId, operationId, progress); - executor.execute(() -> { - Object result = null; - Map error = null; - try { - Object value = invoke.invoke(context); - cancellation.throwIfCancelled(); - result = shape.apply(value); - } catch (Throwable failure) { - if (!cancellation.isCancelled()) { - log((functionId != null ? "Function " + functionId : "A request") + " failed:", failure); - } - error = toResponseError(failure, cancellation); - } - try { - if (onSettled != null) { - onSettled.run(); - } - } finally { - synchronized (lock) { - incoming.remove(key); - } - // Work belongs to its call: tools still running when the handler - // returned end now. Other calls have their own cancellation. - cancellation.cancel("The call ended."); - // Answered whatever the clean-up did, so Lithe never waits for - // its timeout. - respond(id, result, error); - } - }); - } - - private Backend declareBuild(String method, Handler handler) { - if (handler == null) { - throw new IllegalArgumentException("A build handler must not be null."); - } - synchronized (lock) { - if (phase != Phase.IDLE) { - throw new PluginSdkError("sdk.build.lateDeclaration", "Declare build handlers before listen()."); - } - buildHandlers.put(method, handler); - } - return this; - } - - private static void checkDeclared(String method, Object handler) { - if (method == null || Protocol.isReservedMethod(method)) { - throw new PluginSdkError("sdk.method.reserved", method + " is managed by the SDK and Lithe."); - } - if (handler == null) { - throw new IllegalArgumentException("A handler must not be null."); - } - } - - private void checkNewFunction(String id, Handler handler) { - if (!Protocol.isFunctionId(id)) { - throw new PluginSdkError("sdk.function.invalidID", "Function ID \"" + id - + "\" must be dot-separated lowerCamelCase segments of at most 128 bytes."); - } - if (handler == null) { - throw new IllegalArgumentException("A function handler must not be null."); - } - if (functions.containsKey(id)) { - throw new PluginSdkError("sdk.function.duplicate", "Function " + id + " is already declared."); - } - if (functions.size() >= Protocol.MAX_FUNCTIONS) { - throw new PluginSdkError("sdk.function.limit", - "A backend serves at most " + Protocol.MAX_FUNCTIONS + " functions."); - } - } - - /** True until the {@code initialize} answer fixes the initial function list. */ - private boolean beforeAnswer() { - return phase == Phase.IDLE || phase == Phase.LISTENING || phase == Phase.INITIALIZING; - } - - private void requireFunctions() { - if (phase != Phase.READY || !negotiated.functions()) { - throw new PluginSdkError("sdk.functions.unavailable", "The host has not negotiated plugin functions."); - } - } - - private static PluginSdkError unknownFunction(String id) { - return new PluginSdkError("sdk.function.unknown", "Function " + id + " is not registered."); - } - - private CompletableFuture queueRegistration(Supplier> change) { - synchronized (lock) { - CompletableFuture next = registrations.thenComposeAsync(ignored -> { - try { - return change.get(); - } catch (RuntimeException error) { - return failed(error); - } - }, executor); - registrations = next.handle((result, error) -> null); - return next; - } - } - - private void checkOutgoing(String method) { - if (method == null || Protocol.isReservedMethod(method)) { - throw new PluginSdkError("sdk.method.reserved", method - + " is reserved. A backend never asks the host to run a domain operation or a function."); - } - if (phase != Phase.READY) { - throw new PluginSdkError("sdk.session.notReady", "The session is not initialized."); - } - } - - private CompletableFuture request(String method, Object params) { - String id = "sdk-" + nextRequestId.getAndIncrement(); - CompletableFuture future = new CompletableFuture<>(); - synchronized (lock) { - if (phase == Phase.EXITED) { - future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); - return future; - } - outgoing.put(id, new Pending(future, method)); - } - Map message = message(id, method); - if (params != null) { - message.put("params", params); - } - write(message); - return future; - } - - private void settleOutgoing(Map message) { - Object id = message.get("id"); - Pending pending; - synchronized (lock) { - pending = id instanceof String ? outgoing.remove(id) : null; - } - if (pending == null) { - return; - } - Object error = message.get("error"); - // Completed on a handler thread: the reader thread never runs plugin code. - if (error instanceof Map) { - Map failure = Json.object(error); - Object data = failure.get("data"); - String code = data instanceof Map ? Json.string(Json.object(data), "code") : null; - String text = Json.string(failure, "message"); - Object rpcCode = failure.get("code"); - PluginSdkError rejection = new PluginSdkError( - code == null ? "sdk.host.error" : code, - text == null ? pending.method + " failed." : text, - rpcCode instanceof Number ? ((Number) rpcCode).intValue() : null); - executor.execute(() -> pending.future.completeExceptionally(rejection)); - } else { - Object result = message.get("result"); - executor.execute(() -> pending.future.complete(result)); - } - } - - private void cancelAll(String reason) { - List calls; - synchronized (lock) { - calls = new ArrayList<>(incoming.values()); - } - for (Call call : calls) { - call.cancellation.cancel(reason); - } - } - - private void finish(int code) { - List pending; - synchronized (lock) { - if (phase == Phase.EXITED) { - return; - } - phase = Phase.EXITED; - exitCode = code; - pending = new ArrayList<>(outgoing.values()); - outgoing.clear(); - } - cancelAll("The session ended."); - Processes.killAll(); - for (Pending request : pending) { - request.future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); - } - // Running handlers finish on their own threads; nothing new starts. - executor.shutdown(); - } - - private void respond(Object id, Object result, Map error) { - if (phase == Phase.EXITED) { - return; - } - Map message = message(id, null); - if (error != null) { - message.put("error", error); - } else { - message.put("result", result); - } - try { - writeEncoded(Framing.encode(message)); - } catch (Json.JsonException failure) { - // A result JSON cannot encode must not end the session. - log("The result could not be encoded:", failure); - Map fallback = message(id, null); - fallback.put("error", toResponseError(failure, null)); - write(fallback); - } - } - - private void write(Map message) { - writeEncoded(Framing.encode(message)); - } - - private void writeEncoded(byte[] frame) { - synchronized (writeLock) { - OutputStream stream = output; - if (stream == null || phase == Phase.EXITED) { - return; - } - try { - stream.write(frame); - stream.flush(); - } catch (IOException error) { - // The host closed the pipe; the input side ends the session. - log("Writing to the host failed: " + error.getMessage()); - } - } - } - - /** - * The JSON-RPC error object for a handler failure. Only {@link PluginError} - * carries a plugin code; any other exception becomes a generic internal - * error so that stack traces and machine paths never reach the host's UI. - */ - static Map toResponseError(Throwable failure, Cancellation cancellation) { - Throwable error = unwrap(failure); - if (cancellation != null && cancellation.isCancelled() && error instanceof CancelledException) { - return error(Protocol.REQUEST_CANCELLED, "The request was cancelled."); - } - if (error instanceof PluginError) { - Map response = error(Protocol.REQUEST_FAILED, Protocol.boundedText(error.getMessage())); - response.put("data", singleton("code", ((PluginError) error).code())); - return response; - } - // Unexpected exceptions often embed absolute paths or internals; the - // caller logs the details to stderr instead. - return error(Protocol.INTERNAL_ERROR, "The plugin backend failed unexpectedly."); - } - - /** - * Stamps the canonical format on a build result, so a handler can return the - * plain document. A result in another format is a backend bug: Core would - * refuse it as {@code plugin.build.invalidOutput}, so it fails here with a log. - */ - static Object buildResult(String method, Object result) { - String expected = BUILD_FORMATS.get(method); - if (!(result instanceof Map)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " must return a " + expected + " object."); - } - Map document = new LinkedHashMap<>(Json.object(result)); - Object format = document.get("format"); - if (format != null && !expected.equals(format)) { - throw new PluginSdkError("sdk.build.invalidResult", - method + " returned format " + format + ", not " + expected + "."); - } - Object version = document.get("version"); - if (version != null && !(version instanceof Number && ((Number) version).doubleValue() == 1)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " returned version " + version + ", not 1."); - } - document.put("format", expected); - document.put("version", 1); - return document; - } - - /** - * A function result as the JSON value Core accepts. A value JSON cannot - * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a - * log line, instead of breaking the response frame or being refused by Core. - */ - static Object functionResult(Object result) { - String encoded; - try { - encoded = Json.stringify(result); - } catch (Json.JsonException error) { - throw new PluginSdkError("sdk.function.invalidResult", "The result is not JSON: " + error.getMessage()); - } - if (encoded.getBytes(StandardCharsets.UTF_8).length > Protocol.MAX_RESULT_BYTES) { - throw new PluginSdkError("sdk.function.invalidResult", - "The result exceeds " + Protocol.MAX_RESULT_BYTES + " bytes."); - } - return result; - } - - private static boolean offers(Object entry, int version) { - if (!(entry instanceof Map)) { - return false; - } - Object versions = ((Map) entry).get("versions"); - if (!(versions instanceof List)) { - return false; - } - for (Object offered : (List) versions) { - if (offered instanceof Number && ((Number) offered).doubleValue() == version) { - return true; - } - } - return false; - } - - /** {@code capabilities.experimental}, or an empty map. */ - private static Map experimental(Object capabilities) { - Object experimental = capabilities instanceof Map ? ((Map) capabilities).get("experimental") : null; - return experimental instanceof Map ? Json.object(experimental) : Collections.emptyMap(); - } - - /** The JSON-RPC ID as a map key; numbers and strings never collide. */ - private static String requestKey(Object id) { - if (id instanceof Number) { - Number number = (Number) id; - return "number:" + (number.doubleValue() == number.longValue() ? Long.toString(number.longValue()) : number); - } - return (id instanceof String ? "string:" : "other:") + id; - } - - private static Map message(Object id, String method) { - Map message = new LinkedHashMap<>(); - message.put("jsonrpc", "2.0"); - if (id != null || method == null) { - message.put("id", id); - } - if (method != null) { - message.put("method", method); - } - return message; - } - - private static Map error(int code, String text) { - Map error = new LinkedHashMap<>(); - error.put("code", code); - error.put("message", text); - return error; - } - - private static Map singleton(String key, Object value) { - Map map = new LinkedHashMap<>(); - map.put(key, value); - return map; - } - - private static Throwable unwrap(Throwable error) { - Throwable current = error; - while (current instanceof CompletionException && current.getCause() != null) { - current = current.getCause(); - } - return current; - } - - private static CompletableFuture failed(Throwable error) { - CompletableFuture future = new CompletableFuture<>(); - future.completeExceptionally(error); - return future; - } - - /** One host request being handled. */ - private static final class Call { - final Cancellation cancellation; - final String functionId; - - Call(Cancellation cancellation, String functionId) { - this.cancellation = cancellation; - this.functionId = functionId; - } - } - - /** One request this backend sent to the host. */ - private static final class Pending { - final CompletableFuture future; - final String method; - - Pending(CompletableFuture future, String method) { - this.future = future; - this.method = method; - } - } - - /** Daemon threads, so a handler still running never keeps the process alive after exit. */ - private static final class ThreadNamer implements java.util.concurrent.ThreadFactory { - private final AtomicInteger next = new AtomicInteger(1); - - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "lithe-sdk-call-" + next.getAndIncrement()); - thread.setDaemon(true); - return thread; - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java deleted file mode 100644 index 5193590824..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.util.List; - -/** - * What a handler gets besides its params: the call's cancellation, tools that - * end with the call, the backend's log, and progress for plugin functions. - */ -public final class CallContext { - /** Sends one progress report; absent outside plugin functions. */ - interface ProgressSink { - void report(String message, Double percentage); - } - - private final Cancellation cancellation; - private final Backend backend; - private final String functionId; - private final String operationId; - private final ProgressSink progress; - - CallContext(Cancellation cancellation, Backend backend, String functionId, String operationId, ProgressSink progress) { - this.cancellation = cancellation; - this.backend = backend; - this.functionId = functionId; - this.operationId = operationId; - this.progress = progress; - } - - /** - * Cancelled when the host cancels the call, the backend shuts down, or the - * call ends; work a handler leaves running after it returned is stopped. - */ - public Cancellation cancellation() { - return cancellation; - } - - public boolean isCancelled() { - return cancellation.isCancelled(); - } - - /** Throws {@link CancelledException} once the call was cancelled. */ - public void throwIfCancelled() { - cancellation.throwIfCancelled(); - } - - /** - * Runs a tool that belongs to this call: cancelling the call or ending it - * stops the tool, and the backend shutting down ends it. - */ - public Processes.Result run(List command, Processes.Options options) throws IOException { - Processes.Options settings = options == null ? new Processes.Options() : options.copy(); - Cancellation own = settings.cancellation(); - settings.cancellation(own == null ? cancellation : cancellation.combinedWith(own)); - return Processes.run(command, settings); - } - - /** Runs a tool that belongs to this call with default options. */ - public Processes.Result run(List command) throws IOException { - return run(command, null); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - public void log(String message) { - backend.log(message); - } - - /** - * Reports progress of a plugin function. {@code message} is cut to 1024 - * bytes and {@code percentage} rounded into 0-100; either may be null. - * Reports after the call ended, and from build requests, are dropped. - */ - public void progress(String message, Double percentage) { - if (progress != null) { - progress.report(message, percentage); - } - } - - /** The function this call runs, or null for a build or LSP request. */ - public String functionId() { - return functionId; - } - - /** The host's operation ID of a function call, or null. */ - public String operationId() { - return operationId; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java deleted file mode 100644 index 007562ac24..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java +++ /dev/null @@ -1,104 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.util.ArrayList; -import java.util.List; - -/** - * Cancellation of one call, or of work a plugin manages itself. Listeners run - * once, on the thread that cancels; a listener added after cancellation runs - * immediately. A listener that throws is logged to stderr and does not keep - * the others, or the thread that cancelled, from running on. Cancelling never interrupts threads, because an interrupt - * closes NIO channels a handler may be writing state through; long work checks - * {@link #isCancelled()} or {@link #throwIfCancelled()} instead. - */ -public final class Cancellation { - private final Object lock = new Object(); - private final List listeners = new ArrayList<>(); - private String reason; - - /** Cancels with {@code reason}; later calls have no effect. */ - public void cancel(String reason) { - List pending; - synchronized (lock) { - if (this.reason != null) { - return; - } - this.reason = reason == null ? "The request was cancelled." : reason; - pending = new ArrayList<>(listeners); - listeners.clear(); - } - for (Runnable listener : pending) { - runIsolated(listener); - } - } - - public boolean isCancelled() { - synchronized (lock) { - return reason != null; - } - } - - /** Throws {@link CancelledException} once cancelled. */ - public void throwIfCancelled() { - String current; - synchronized (lock) { - current = reason; - } - if (current != null) { - throw new CancelledException(current); - } - } - - /** - * Runs {@code listener} on cancellation. The returned handle removes it, - * for work that finished first. - */ - public Registration onCancel(Runnable listener) { - synchronized (lock) { - if (reason == null) { - listeners.add(listener); - return () -> { - synchronized (lock) { - listeners.remove(listener); - } - }; - } - } - runIsolated(listener); - return () -> {}; - } - - /** - * A token cancelled when either this one or {@code other} is; for work that - * a plugin may also stop for its own reasons. - */ - public Cancellation combinedWith(Cancellation other) { - Cancellation combined = new Cancellation(); - Registration first = onCancel(() -> combined.cancel(reasonOrDefault())); - Registration second = other.onCancel(() -> combined.cancel(other.reasonOrDefault())); - combined.onCancel(() -> { - first.remove(); - second.remove(); - }); - return combined; - } - - private static void runIsolated(Runnable listener) { - try { - listener.run(); - } catch (RuntimeException error) { - System.err.println("A cancellation listener failed: " + error); - } - } - - private String reasonOrDefault() { - synchronized (lock) { - return reason == null ? "The request was cancelled." : reason; - } - } - - /** Removes a cancellation listener. */ - public interface Registration { - void remove(); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java deleted file mode 100644 index dd016767e2..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * Thrown when the call a piece of work belongs to was cancelled or ended. - * A handler normally lets it propagate; the SDK then answers the host with - * {@code RequestCancelled}. - */ -public class CancelledException extends RuntimeException { - private static final long serialVersionUID = 1L; - - public CancelledException(String message) { - super(message); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java deleted file mode 100644 index 36f76f63d8..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java +++ /dev/null @@ -1,138 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * LSP base-protocol framing: {@code Content-Length} headers followed by a UTF-8 - * JSON body. Lithe talks to every plugin backend this way, so the SDK owns the - * framing and plugin code never writes to stdout itself. - */ -public final class Framing { - /** Largest header block accepted from the host. */ - static final int MAX_HEADER_BYTES = 8192; - - /** Largest body accepted from the host. Larger frames end the session. */ - public static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; - - /** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ - public static final Object MALFORMED = new Object() { - @Override - public String toString() { - return "MALFORMED"; - } - }; - - private static final byte[] HEADER_END = {'\r', '\n', '\r', '\n'}; - - private Framing() {} - - /** A framing error the session cannot recover from. */ - public static final class FramingException extends RuntimeException { - private static final long serialVersionUID = 1L; - - FramingException(String message) { - super(message); - } - } - - /** Encodes one JSON-RPC message as a complete frame. */ - public static byte[] encode(Object message) { - byte[] body = Json.stringify(message).getBytes(StandardCharsets.UTF_8); - byte[] header = ("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII); - byte[] frame = Arrays.copyOf(header, header.length + body.length); - System.arraycopy(body, 0, frame, header.length, body.length); - return frame; - } - - /** Incrementally splits a byte stream into decoded JSON-RPC messages. */ - public static final class Decoder { - private byte[] buffer = new byte[8192]; - private int length; - - /** - * Appends {@code count} bytes of {@code chunk} and returns every complete - * message they finished. A body that is not JSON is returned as - * {@link #MALFORMED} so the caller can report it without losing the - * framing position. - */ - public List push(byte[] chunk, int offset, int count) { - ensureCapacity(length + count); - System.arraycopy(chunk, offset, buffer, length, count); - length += count; - List messages = new ArrayList<>(); - int start = 0; - for (;;) { - int headerEnd = indexOfHeaderEnd(start); - int available = length - start; - // The same bound whether the header arrived whole or in pieces. - if ((headerEnd >= 0 && headerEnd - start > MAX_HEADER_BYTES) - || (headerEnd < 0 && available > MAX_HEADER_BYTES)) { - throw new FramingException("The message header is too long."); - } - if (headerEnd < 0) { - break; - } - int bodyLength = contentLength(new String(buffer, start, headerEnd - start, StandardCharsets.US_ASCII)); - int bodyStart = headerEnd + HEADER_END.length; - if ((long) length < (long) bodyStart + bodyLength) { - break; - } - String body = new String(buffer, bodyStart, bodyLength, StandardCharsets.UTF_8); - start = bodyStart + bodyLength; - try { - messages.add(Json.parse(body)); - } catch (Json.JsonException error) { - messages.add(MALFORMED); - } - } - if (start > 0) { - System.arraycopy(buffer, start, buffer, 0, length - start); - length -= start; - } - return messages; - } - - private int indexOfHeaderEnd(int from) { - for (int index = from; index + HEADER_END.length <= length; index++) { - if (buffer[index] == '\r' && buffer[index + 1] == '\n' - && buffer[index + 2] == '\r' && buffer[index + 3] == '\n') { - return index; - } - } - return -1; - } - - private void ensureCapacity(int required) { - if (required > buffer.length) { - buffer = Arrays.copyOf(buffer, Math.max(required, buffer.length * 2)); - } - } - } - - private static int contentLength(String header) { - for (String line : header.split("\r\n")) { - int separator = line.indexOf(':'); - if (separator < 0) { - continue; - } - if (!line.substring(0, separator).trim().equalsIgnoreCase("content-length")) { - continue; - } - String value = line.substring(separator + 1).trim(); - if (!value.matches("[0-9]+")) { - break; - } - // More than ten digits exceeds the limit whatever they are. - long length = value.length() > 10 ? Long.MAX_VALUE : Long.parseLong(value); - if (length > MAX_FRAME_BYTES) { - throw new FramingException("A message of " + value + " bytes exceeds the " - + MAX_FRAME_BYTES + "-byte limit."); - } - return (int) length; - } - throw new FramingException("A message has no valid Content-Length header."); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java deleted file mode 100644 index 64ff3f59cc..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java +++ /dev/null @@ -1,426 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Minimal JSON reader and writer for protocol messages and stored state. - * - *

The SDK carries its own codec so that a plugin can shade it next to any - * JSON library without version conflicts. Values map to plain Java types: - * objects to {@code Map} (insertion-ordered), arrays to - * {@code List}, strings to {@code String}, booleans to - * {@code Boolean}, {@code null} to {@code null}, integers to {@code Long} (or - * {@code BigInteger} beyond its range), and other numbers to {@code Double}. - */ -public final class Json { - /** Deeper input is refused instead of overflowing the reader's stack. */ - private static final int MAX_DEPTH = 512; - - private Json() {} - - /** A value that is not JSON, or one JSON cannot represent. */ - public static final class JsonException extends RuntimeException { - private static final long serialVersionUID = 1L; - - public JsonException(String message) { - super(message); - } - } - - /** Parses one JSON document; trailing content other than whitespace is an error. */ - public static Object parse(String text) { - Reader reader = new Reader(text); - reader.skipWhitespace(); - Object value = reader.value(0); - reader.skipWhitespace(); - if (reader.position != text.length()) { - throw reader.error("Unexpected content after the value"); - } - return value; - } - - /** - * Encodes {@code value}. Maps need string keys; numbers must be finite. - * Arrays of objects and any {@link Collection} encode as JSON arrays. - */ - public static String stringify(Object value) { - StringBuilder output = new StringBuilder(); - write(output, value, 0); - return output.toString(); - } - - /** {@code value} as an object, or an empty map when it is {@code null}. */ - @SuppressWarnings("unchecked") - public static Map object(Object value) { - if (value == null) { - return new LinkedHashMap<>(); - } - if (value instanceof Map) { - return (Map) value; - } - throw new JsonException("Expected a JSON object."); - } - - /** The string at {@code key}, or {@code null} when it is missing or not a string. */ - public static String string(Map object, String key) { - Object value = object.get(key); - return value instanceof String ? (String) value : null; - } - - private static void write(StringBuilder output, Object value, int depth) { - if (depth > MAX_DEPTH) { - throw new JsonException("The value is nested too deeply, or contains itself."); - } - if (value == null) { - output.append("null"); - } else if (value instanceof String || value instanceof Character) { - quote(output, value.toString()); - } else if (value instanceof Boolean) { - output.append(value.toString()); - } else if (value instanceof Double || value instanceof Float) { - output.append(decimal(((Number) value).doubleValue())); - } else if (value instanceof Long || value instanceof Integer || value instanceof Short - || value instanceof Byte || value instanceof BigInteger) { - output.append(value.toString()); - } else if (value instanceof BigDecimal) { - output.append(((BigDecimal) value).toString()); - } else if (value instanceof Map) { - output.append('{'); - boolean first = true; - for (Map.Entry entry : ((Map) value).entrySet()) { - if (!(entry.getKey() instanceof String)) { - throw new JsonException("Object keys must be strings."); - } - if (!first) { - output.append(','); - } - first = false; - quote(output, (String) entry.getKey()); - output.append(':'); - write(output, entry.getValue(), depth + 1); - } - output.append('}'); - } else if (value instanceof Collection) { - writeArray(output, ((Collection) value).toArray(), depth); - } else if (value instanceof Object[]) { - writeArray(output, (Object[]) value, depth); - } else { - throw new JsonException("A " + value.getClass().getName() + " is not a JSON value."); - } - } - - private static void writeArray(StringBuilder output, Object[] values, int depth) { - output.append('['); - for (int index = 0; index < values.length; index++) { - if (index > 0) { - output.append(','); - } - write(output, values[index], depth + 1); - } - output.append(']'); - } - - private static String decimal(double value) { - if (Double.isNaN(value) || Double.isInfinite(value)) { - throw new JsonException("JSON has no representation for " + value + "."); - } - // Integral values print without a fraction, as JavaScript prints them. - if (value == Math.rint(value) && Math.abs(value) < 1e15) { - return Long.toString((long) value); - } - return Double.toString(value); - } - - private static void quote(StringBuilder output, String text) { - output.append('"'); - for (int index = 0; index < text.length(); index++) { - char character = text.charAt(index); - switch (character) { - case '"': - output.append("\\\""); - break; - case '\\': - output.append("\\\\"); - break; - case '\n': - output.append("\\n"); - break; - case '\r': - output.append("\\r"); - break; - case '\t': - output.append("\\t"); - break; - case '\b': - output.append("\\b"); - break; - case '\f': - output.append("\\f"); - break; - default: - if (character < 0x20 || isLoneSurrogate(text, index)) { - // A lone surrogate has no UTF-8 encoding; escaping it - // keeps the frame valid, as JSON.stringify does. - output.append(String.format("\\u%04x", (int) character)); - } else { - output.append(character); - } - } - } - output.append('"'); - } - - private static boolean isLoneSurrogate(String text, int index) { - char character = text.charAt(index); - if (Character.isHighSurrogate(character)) { - return index + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(index + 1)); - } - if (Character.isLowSurrogate(character)) { - return index == 0 || !Character.isHighSurrogate(text.charAt(index - 1)); - } - return false; - } - - private static final class Reader { - private final String text; - private int position; - - Reader(String text) { - this.text = text; - } - - Object value(int depth) { - if (depth > MAX_DEPTH) { - throw error("The document is nested too deeply"); - } - if (position >= text.length()) { - throw error("Unexpected end of input"); - } - char character = text.charAt(position); - switch (character) { - case '{': - return object(depth); - case '[': - return array(depth); - case '"': - return string(); - case 't': - return literal("true", Boolean.TRUE); - case 'f': - return literal("false", Boolean.FALSE); - case 'n': - return literal("null", null); - default: - if (character == '-' || (character >= '0' && character <= '9')) { - return number(); - } - throw error("Unexpected character"); - } - } - - private Map object(int depth) { - Map result = new LinkedHashMap<>(); - position++; - skipWhitespace(); - if (peek() == '}') { - position++; - return result; - } - for (;;) { - skipWhitespace(); - if (peek() != '"') { - throw error("Expected a property name"); - } - String key = string(); - skipWhitespace(); - expect(':'); - skipWhitespace(); - result.put(key, value(depth + 1)); - skipWhitespace(); - char next = peek(); - position++; - if (next == '}') { - return result; - } - if (next != ',') { - throw error("Expected ',' or '}'"); - } - } - } - - private List array(int depth) { - List result = new ArrayList<>(); - position++; - skipWhitespace(); - if (peek() == ']') { - position++; - return result; - } - for (;;) { - skipWhitespace(); - result.add(value(depth + 1)); - skipWhitespace(); - char next = peek(); - position++; - if (next == ']') { - return result; - } - if (next != ',') { - throw error("Expected ',' or ']'"); - } - } - } - - private String string() { - position++; - StringBuilder result = new StringBuilder(); - for (;;) { - if (position >= text.length()) { - throw error("Unterminated string"); - } - char character = text.charAt(position++); - if (character == '"') { - return result.toString(); - } - if (character < 0x20) { - throw error("Control character in string"); - } - if (character != '\\') { - result.append(character); - continue; - } - if (position >= text.length()) { - throw error("Unterminated escape"); - } - char escape = text.charAt(position++); - switch (escape) { - case '"': - case '\\': - case '/': - result.append(escape); - break; - case 'b': - result.append('\b'); - break; - case 'f': - result.append('\f'); - break; - case 'n': - result.append('\n'); - break; - case 'r': - result.append('\r'); - break; - case 't': - result.append('\t'); - break; - case 'u': - if (position + 4 > text.length()) { - throw error("Incomplete unicode escape"); - } - int code = 0; - for (int index = 0; index < 4; index++) { - int digit = Character.digit(text.charAt(position++), 16); - if (digit < 0) { - throw error("Invalid unicode escape"); - } - code = code * 16 + digit; - } - result.append((char) code); - break; - default: - throw error("Invalid escape"); - } - } - } - - private Object number() { - int start = position; - if (peek() == '-') { - position++; - } - if (peek() == '0') { - position++; - } else if (isDigit(peek())) { - digits(); - } else { - throw error("Invalid number"); - } - boolean integral = true; - if (peek() == '.') { - integral = false; - position++; - if (!isDigit(peek())) { - throw error("Invalid number"); - } - digits(); - } - if (peek() == 'e' || peek() == 'E') { - integral = false; - position++; - if (peek() == '+' || peek() == '-') { - position++; - } - if (!isDigit(peek())) { - throw error("Invalid number"); - } - digits(); - } - String literal = text.substring(start, position); - if (integral) { - BigInteger value = new BigInteger(literal); - return value.bitLength() < 64 ? (Object) value.longValue() : value; - } - return Double.parseDouble(literal); - } - - private void digits() { - while (isDigit(peek())) { - position++; - } - } - - private Object literal(String word, Object value) { - if (!text.startsWith(word, position)) { - throw error("Unexpected literal"); - } - position += word.length(); - return value; - } - - private void expect(char character) { - if (peek() != character) { - throw error("Expected '" + character + "'"); - } - position++; - } - - private char peek() { - return position < text.length() ? text.charAt(position) : '\0'; - } - - void skipWhitespace() { - while (position < text.length()) { - char character = text.charAt(position); - if (character != ' ' && character != '\t' && character != '\n' && character != '\r') { - return; - } - position++; - } - } - - JsonException error(String message) { - return new JsonException(message + " at offset " + position + "."); - } - - private static boolean isDigit(char character) { - return character >= '0' && character <= '9'; - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java deleted file mode 100644 index aa7b972de9..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * A failure a handler reports on purpose. Core ends the call with the stable - * {@code plugin.function.failed} code and forwards {@link #code()} as - * {@code pluginErrorCode}, so the plugin's own UI can tell its failures apart. - * Lithe never branches on it. - * - *

The message is shown to the user as written (Core keeps its first 1024 - * bytes), so it must not contain stack traces or machine paths. - */ -public class PluginError extends RuntimeException { - private static final long serialVersionUID = 1L; - - private final String code; - - /** - * @param code identifier of at most 64 bytes, such as {@code maven.snapshotLocked} - * @param message user-facing text - */ - public PluginError(String code, String message) { - this(code, message, null); - } - - /** As {@link #PluginError(String, String)}; {@code cause} is kept for logs only. */ - public PluginError(String code, String message, Throwable cause) { - super(message, cause); - if (!Protocol.isPluginErrorCode(code)) { - throw new IllegalArgumentException("Plugin error code \"" + code - + "\" must start with a letter, use letters, digits, \".\", \"_\" or \"-\", and be at most 64 bytes."); - } - this.code = code; - } - - public String code() { - return code; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java deleted file mode 100644 index 043ba52c6e..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * An SDK usage error raised to the plugin author, or a failure the host - * reported for a request this backend sent. {@link #rpcCode()} carries the - * host's JSON-RPC error code in the latter case. - */ -public class PluginSdkError extends RuntimeException { - private static final long serialVersionUID = 1L; - - private final String code; - private final Integer rpcCode; - - public PluginSdkError(String code, String message) { - this(code, message, null); - } - - public PluginSdkError(String code, String message, Integer rpcCode) { - super(message); - this.code = code; - this.rpcCode = rpcCode; - } - - public String code() { - return code; - } - - /** The host's JSON-RPC error code, or {@code null} for a local usage error. */ - public Integer rpcCode() { - return rpcCode; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java deleted file mode 100644 index 76c36ca7ae..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java +++ /dev/null @@ -1,623 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Child processes a backend starts for one call. - * - *

Lithe's host offers no process capability in protocol version 1, so a - * backend runs its tools itself; this helper makes sure that the call ending, - * cancelling it, or the backend shutting down also ends the tools it started. - * - *

Process trees: on macOS and Linux every tool leads its own process group, - * as in the Node SDK, on every Java version. Java has no API for that, so the - * tool starts through the system {@code perl}, which calls {@code setpgrp} and - * then replaces itself with the tool, keeping its PID. A stop signals the whole - * group, and once the tool exits on its own the members it left behind, such - * as a background worker, are killed too; descendants that leave the group - * with {@code setsid} are out of reach. Windows, and a system without - * {@code perl}, fall back to {@code ProcessHandle} on Java 9 and newer, found - * by reflection so the SDK still loads on Java 8; it reaches only the - * descendants still attached to the tool. Lithe starts every backend inside - * its own process scope ({@code LITHE_PLUGIN_BACKEND_SCOPE}) and ends whatever - * is left of it when the session ends; that remains the final boundary. - */ -public final class Processes { - /** - * Variables Lithe sets on the backend so it can find and end every process - * the backend started, even one that left its parent. A tool always - * inherits them, also when the plugin replaces the environment. - */ - static final String[] HOST_MARKERS = {"LITHE_PROCESS_SCOPE", "LITHE_PLUGIN_BACKEND_SCOPE"}; - - /** Every tool started through {@link #run} that has not exited or been stopped. */ - private static final Set RUNNING = Collections.newSetFromMap(new ConcurrentHashMap<>()); - - private static final ScheduledExecutorService TIMERS = Executors.newSingleThreadScheduledExecutor(runnable -> { - Thread thread = new Thread(runnable, "lithe-sdk-process-timer"); - thread.setDaemon(true); - return thread; - }); - - private static final ProcessTree TREE = ProcessTree.load(); - - private static final ProcessGroups GROUPS = ProcessGroups.load(); - - private Processes() {} - - /** Options of one {@link #run}; every setter returns this object. */ - public static final class Options { - private Path directory; - private Map environment; - private Cancellation cancellation; - private int maxOutputBytes = 1 << 20; - private long killGraceMillis = 2000; - private Consumer onStdout; - private Consumer onStderr; - - /** Working directory; defaults to the backend's. */ - public Options directory(Path value) { - directory = value; - return this; - } - - /** Replaces the environment; the host's process markers are carried over regardless. */ - public Options environment(Map value) { - environment = value; - return this; - } - - /** Cancelling it stops the tool and makes {@link #run} throw {@link CancelledException}. */ - public Options cancellation(Cancellation value) { - cancellation = value; - return this; - } - - /** Captured bytes per stream; default 1 MiB. Listeners still see everything. */ - public Options maxOutputBytes(int value) { - maxOutputBytes = value; - return this; - } - - /** Delay between the polite and the forced stop; default 2000. */ - public Options killGraceMillis(long value) { - killGraceMillis = value; - return this; - } - - /** Receives stdout as it arrives, decoded as UTF-8 without splitting characters. */ - public Options onStdout(Consumer value) { - onStdout = value; - return this; - } - - /** Receives stderr as it arrives, decoded as UTF-8 without splitting characters. */ - public Options onStderr(Consumer value) { - onStderr = value; - return this; - } - - Cancellation cancellation() { - return cancellation; - } - - Options copy() { - Options copy = new Options(); - copy.directory = directory; - copy.environment = environment; - copy.cancellation = cancellation; - copy.maxOutputBytes = maxOutputBytes; - copy.killGraceMillis = killGraceMillis; - copy.onStdout = onStdout; - copy.onStderr = onStderr; - return copy; - } - } - - /** Exit status and captured output of a tool. */ - public static final class Result { - private final int exitCode; - private final String stdout; - private final String stderr; - private final boolean truncated; - - Result(int exitCode, String stdout, String stderr, boolean truncated) { - this.exitCode = exitCode; - this.stdout = stdout; - this.stderr = stderr; - this.truncated = truncated; - } - - /** The exit code; on POSIX a tool ended by signal N reports 128 + N. */ - public int exitCode() { - return exitCode; - } - - public String stdout() { - return stdout; - } - - public String stderr() { - return stderr; - } - - /** True when either stream exceeded {@code maxOutputBytes}. */ - public boolean truncated() { - return truncated; - } - } - - /** - * Runs {@code command} without a shell and returns its exit status and - * captured output. A non-zero exit returns normally; the caller decides - * what it means for its tool. Cancellation stops the tool and throws - * {@link CancelledException} once it ended. - */ - public static Result run(List command, Options options) throws IOException { - Options settings = options == null ? new Options() : options; - Cancellation cancellation = settings.cancellation; - if (cancellation != null) { - cancellation.throwIfCancelled(); - } - if (command.isEmpty()) { - throw new IllegalArgumentException("A command needs an executable."); - } - ProcessBuilder builder = new ProcessBuilder(new ArrayList<>(command)); - if (settings.directory != null) { - builder.directory(settings.directory.toFile()); - } - if (settings.environment != null) { - Map environment = builder.environment(); - environment.clear(); - environment.putAll(settings.environment); - for (String name : HOST_MARKERS) { - String value = System.getenv(name); - if (value == null) { - environment.remove(name); - } else { - environment.put(name, value); - } - } - } - boolean grouped = GROUPS.available(); - if (grouped) { - builder.command(GROUPS.launcher(command, builder.environment())); - } - Process process = builder.start(); - // Tools get no input; an open pipe would let one wait on it forever. - process.getOutputStream().close(); - Running entry = new Running(process, grouped ? GROUPS.leader(process) : 0); - RUNNING.add(entry); - Capture stdout = new Capture(process.getInputStream(), settings.maxOutputBytes, settings.onStdout, "stdout"); - Capture stderr = new Capture(process.getErrorStream(), settings.maxOutputBytes, settings.onStderr, "stderr"); - Cancellation.Registration registration = cancellation == null - ? () -> {} - : cancellation.onCancel(() -> entry.stop(settings.killGraceMillis)); - int exitCode; - try { - exitCode = waitFor(process, entry); - registration.remove(); - // The tool ended on its own; what it left running, such as a - // background worker, ends with it. A stopped tool stays registered - // until its forced stop, which reaches the same members. - entry.endLeftovers(); - // A descendant out of reach can keep the inherited pipes open after - // the tool exited; the output then ends at the grace period. - stdout.join(settings.killGraceMillis); - stderr.join(settings.killGraceMillis); - } finally { - registration.remove(); - if (!entry.stopping()) { - RUNNING.remove(entry); - } - } - if (cancellation != null) { - cancellation.throwIfCancelled(); - } - String errors = stderr.text(); - if (grouped && exitCode == ProcessGroups.EXEC_FAILED && errors.startsWith(ProcessGroups.EXEC_FAILURE)) { - // The launcher could not start the tool; report it as ProcessBuilder would. - throw new IOException("Cannot run program \"" + command.get(0) + "\": " - + errors.substring(ProcessGroups.EXEC_FAILURE.length()).trim()); - } - return new Result(exitCode, stdout.text(), errors, stdout.truncated() || stderr.truncated()); - } - - /** Runs {@code command} with default options. */ - public static Result run(List command) throws IOException { - return run(command, null); - } - - /** - * Forcibly ends every tool still running. The backend calls it on shutdown, - * on {@code exit}, and when the host closes stdin, so no tool outlives the - * session that started it. - */ - public static void killAll() { - for (Running entry : new ArrayList<>(RUNNING)) { - entry.forceStop(); - } - RUNNING.clear(); - } - - /** Number of tools still running or being stopped, for tests and diagnostics. */ - public static int runningCount() { - return RUNNING.size(); - } - - private static int waitFor(Process process, Running entry) { - boolean interrupted = false; - try { - for (;;) { - try { - return process.waitFor(); - } catch (InterruptedException error) { - // The tool still belongs to this call; end it before giving - // the interrupt back to the caller. - interrupted = true; - entry.forceStop(); - } - } - } finally { - if (interrupted) { - Thread.currentThread().interrupt(); - } - } - } - - /** One started tool and its stop state. */ - private static final class Running { - private final Process process; - /** The tool's process group, or 0 when it has none of its own. */ - private final long group; - private final Object lock = new Object(); - private boolean stopping; - private List tree = Collections.emptyList(); - private ScheduledFuture forceTimer; - - Running(Process process, long group) { - this.process = process; - this.group = group; - } - - /** Kills what the tool left after exiting on its own, unless a stop owns that. */ - void endLeftovers() { - synchronized (lock) { - if (stopping) { - return; - } - } - if (group > 0) { - // While any member lives the group ID cannot be reused, so this - // reaches only what this tool started; an empty group is ignored. - GROUPS.signal(group, true); - } else { - // Windows keeps the parent link of an exited tool, so its - // descendants are still found there. - TREE.destroy(TREE.descendants(process), true); - } - } - - boolean stopping() { - synchronized (lock) { - return stopping; - } - } - - /** Asks the tree to stop, then forces it after {@code graceMillis}. */ - void stop(long graceMillis) { - synchronized (lock) { - if (stopping) { - return; - } - stopping = true; - if (group > 0) { - GROUPS.signal(group, false); - } else { - // Descendants are listed before the tool is signalled: once - // it exits they are reparented and no longer reachable from it. - tree = TREE.descendants(process); - TREE.destroy(tree, false); - } - // Also covers a tool that has not formed its group yet. - process.destroy(); - forceTimer = TIMERS.schedule(this::forceStop, graceMillis, TimeUnit.MILLISECONDS); - } - } - - void forceStop() { - List known; - synchronized (lock) { - stopping = true; - if (forceTimer != null) { - forceTimer.cancel(false); - } - known = new ArrayList<>(tree); - if (group <= 0) { - known.addAll(TREE.descendants(process)); - } - } - if (group > 0) { - GROUPS.signal(group, true); - } - TREE.destroy(known, true); - process.destroyForcibly(); - RUNNING.remove(this); - } - } - - /** Reads one output stream on its own thread, keeping at most {@code limit} bytes. */ - private static final class Capture { - private final ByteArrayOutputStream kept = new ByteArrayOutputStream(); - private final InputStream input; - private final int limit; - private final Consumer listener; - private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPLACE) - .onUnmappableCharacter(CodingErrorAction.REPLACE); - private final Thread thread; - private volatile boolean truncated; - - Capture(InputStream input, int limit, Consumer listener, String name) { - this.input = input; - this.limit = limit; - this.listener = listener; - thread = new Thread(this::read, "lithe-sdk-" + name); - thread.setDaemon(true); - thread.start(); - } - - private void read() { - byte[] chunk = new byte[8192]; - // Holds a multi-byte character split across reads until it is whole. - ByteBuffer pending = ByteBuffer.allocate(chunk.length + 8); - try { - int count; - while ((count = input.read(chunk)) >= 0) { - synchronized (kept) { - int room = Math.max(0, limit - kept.size()); - kept.write(chunk, 0, Math.min(room, count)); - if (count > room) { - truncated = true; - } - } - if (listener != null) { - pending.put(chunk, 0, count); - pending.flip(); - deliver(pending, false); - pending.compact(); - } - } - if (listener != null) { - pending.flip(); - deliver(pending, true); - } - } catch (IOException error) { - // The stream was closed after the grace period; what was read is kept. - } - } - - private void deliver(ByteBuffer bytes, boolean end) { - CharBuffer characters = CharBuffer.allocate(bytes.remaining() + 1); - decoder.decode(bytes, characters, end); - if (end) { - decoder.flush(characters); - } - characters.flip(); - if (characters.hasRemaining()) { - try { - listener.accept(characters.toString()); - } catch (RuntimeException error) { - System.err.println("An output listener failed: " + error); - } - } - } - - void join(long millis) { - try { - thread.join(Math.max(1, millis)); - if (thread.isAlive()) { - input.close(); - thread.join(millis); - } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } catch (IOException error) { - // Closing an output pipe that already failed changes nothing. - } - } - - String text() { - synchronized (kept) { - return new String(kept.toByteArray(), StandardCharsets.UTF_8); - } - } - - boolean truncated() { - return truncated; - } - } - - /** - * A process group per tool on macOS and Linux. The launcher is - * {@code perl -e SCRIPT -- command...}: it calls - * {@code setpgrp(0, 0)} and replaces itself with the tool, so the PID Java - * reports is the tool's and also its group ID. {@code PERL5OPT} would make - * perl load modules before the script runs, so it is withheld from perl and - * handed back to the tool. Signals go through {@code /bin/kill}, which - * Java 8 needs for a negative PID. - */ - private static final class ProcessGroups { - /** Exit code and stderr prefix of a launcher that could not start the tool. */ - static final int EXEC_FAILED = 127; - static final String EXEC_FAILURE = "lithe-sdk-exec-failed: "; - - private static final String SCRIPT = "my $has = shift; my $opt = shift;" - + " $ENV{PERL5OPT} = $opt if $has;" - + " setpgrp(0, 0);" - + " exec { $ARGV[0] } @ARGV;" - + " print STDERR '" + EXEC_FAILURE + "', \"$!\\n\"; exit " + EXEC_FAILED + ";"; - - /** A signal that has not run after this long is abandoned; the host scope remains. */ - private static final long SIGNAL_TIMEOUT_MILLIS = 2000; - - private final String perl; - private final String kill; - private final Method pid; - - private ProcessGroups(String perl, String kill, Method pid) { - this.perl = perl; - this.kill = kill; - this.pid = pid; - } - - static ProcessGroups load() { - if (System.getProperty("os.name", "").startsWith("Windows")) { - return new ProcessGroups(null, null, null); - } - Method pid; - try { - pid = Process.class.getMethod("pid"); - } catch (NoSuchMethodException error) { - // Java 8 keeps the PID in a private field of UNIXProcess. - pid = null; - } - return new ProcessGroups(executable("/usr/bin/perl", "/bin/perl"), - executable("/bin/kill", "/usr/bin/kill"), pid); - } - - private static String executable(String... candidates) { - for (String candidate : candidates) { - if (new File(candidate).canExecute()) { - return candidate; - } - } - return null; - } - - boolean available() { - return perl != null && kill != null; - } - - List launcher(List command, Map environment) { - String option = environment.remove("PERL5OPT"); - List launcher = new ArrayList<>(Arrays.asList(perl, "-e", SCRIPT, "--", - option == null ? "0" : "1", option == null ? "" : option)); - launcher.addAll(command); - return launcher; - } - - /** The tool's PID, which is its group ID; 0 when it cannot be read. */ - long leader(Process process) { - try { - if (pid != null) { - return ((Number) pid.invoke(process)).longValue(); - } - Field field = process.getClass().getDeclaredField("pid"); - field.setAccessible(true); - return field.getInt(process); - } catch (ReflectiveOperationException | RuntimeException error) { - return 0; - } - } - - /** Signals every member of {@code group}; an empty group is ignored. */ - void signal(long group, boolean forcibly) { - ProcessBuilder builder = new ProcessBuilder(kill, "-s", forcibly ? "KILL" : "TERM", "--", "-" + group); - File discard = new File("/dev/null"); - builder.redirectOutput(discard).redirectError(discard); - try { - Process signal = builder.start(); - signal.getOutputStream().close(); - if (!signal.waitFor(SIGNAL_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { - signal.destroyForcibly(); - } - } catch (IOException error) { - // The host's process scope ends what this could not reach. - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - } - - /** - * {@code ProcessHandle} through reflection: present from Java 9, absent on - * Java 8, where only the direct child can be stopped. - */ - private static final class ProcessTree { - private final Method descendants; - private final Method destroy; - private final Method destroyForcibly; - - private ProcessTree(Method descendants, Method destroy, Method destroyForcibly) { - this.descendants = descendants; - this.destroy = destroy; - this.destroyForcibly = destroyForcibly; - } - - static ProcessTree load() { - try { - Class handle = Class.forName("java.lang.ProcessHandle"); - return new ProcessTree( - Process.class.getMethod("descendants"), - handle.getMethod("destroy"), - handle.getMethod("destroyForcibly")); - } catch (ReflectiveOperationException error) { - return new ProcessTree(null, null, null); - } - } - - List descendants(Process process) { - if (descendants == null) { - return Collections.emptyList(); - } - try { - @SuppressWarnings("unchecked") - Stream stream = (Stream) descendants.invoke(process); - return stream.collect(Collectors.toList()); - } catch (ReflectiveOperationException | RuntimeException error) { - return Collections.emptyList(); - } - } - - void destroy(List handles, boolean forcibly) { - Method method = forcibly ? destroyForcibly : destroy; - if (method == null) { - return; - } - for (Object handle : handles) { - try { - method.invoke(handle); - } catch (ReflectiveOperationException | RuntimeException error) { - // The process already exited, or the platform refused; the - // host's process scope ends what is left. - } - } - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java deleted file mode 100644 index 0c7ce3b6a1..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ /dev/null @@ -1,109 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.nio.charset.StandardCharsets; -import java.util.regex.Pattern; - -/** - * Wire constants and validation shared with Rust Core - * ({@code rust/lithe-core/src/lsp/interface/plugin_function.rs}) and the Node.js - * SDK. The SDK checks the same rules locally so a plugin author sees a mistake - * when registering a handler instead of as a refused session at runtime. - */ -public final class Protocol { - /** Protocol versions this SDK can answer. */ - public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 1; - - public static final String INITIALIZE = "initialize"; - public static final String INITIALIZED = "initialized"; - public static final String SHUTDOWN = "shutdown"; - public static final String EXIT = "exit"; - public static final String CANCEL_REQUEST = "$/cancelRequest"; - public static final String FUNCTION_INVOKE = "lithe/function/invoke"; - public static final String FUNCTION_REGISTER = "lithe/function/register"; - public static final String FUNCTION_UNREGISTER = "lithe/function/unregister"; - public static final String FUNCTION_PROGRESS = "lithe/function/progress"; - public static final String BUILD_MODEL = "lithe/build/model"; - public static final String BUILD_DEPENDENCIES = "lithe/build/dependencies"; - - /** JSON-RPC and LSP error codes the SDK answers with. */ - public static final int PARSE_ERROR = -32700; - public static final int INVALID_REQUEST = -32600; - public static final int METHOD_NOT_FOUND = -32601; - public static final int INVALID_PARAMS = -32602; - public static final int INTERNAL_ERROR = -32603; - public static final int SERVER_NOT_INITIALIZED = -32002; - public static final int REQUEST_FAILED = -32000; - public static final int REQUEST_CANCELLED = -32800; - - public static final int MAX_FUNCTIONS = 256; - public static final int MAX_FUNCTION_ID_BYTES = 128; - public static final int MAX_PLUGIN_ERROR_CODE_BYTES = 64; - public static final int MAX_MESSAGE_BYTES = 1024; - public static final int MAX_RESULT_BYTES = 1 << 20; - - private static final Pattern FUNCTION_SEGMENT = Pattern.compile("[a-z][A-Za-z0-9]*"); - private static final Pattern PLUGIN_ERROR_CODE = Pattern.compile("[A-Za-z][A-Za-z0-9._-]*"); - - private Protocol() {} - - /** Dot-separated lowerCamelCase segments, for example {@code snapshot.refresh}. */ - public static boolean isFunctionId(String value) { - if (value == null || value.isEmpty() || utf8Length(value) > MAX_FUNCTION_ID_BYTES) { - return false; - } - for (String segment : value.split("\\.", -1)) { - if (!FUNCTION_SEGMENT.matcher(segment).matches()) { - return false; - } - } - return true; - } - - /** The plugin error code alphabet Core forwards as {@code pluginErrorCode}. */ - public static boolean isPluginErrorCode(String value) { - return value != null - && utf8Length(value) <= MAX_PLUGIN_ERROR_CODE_BYTES - && PLUGIN_ERROR_CODE.matcher(value).matches(); - } - - /** Cuts {@code value} to {@link #MAX_MESSAGE_BYTES} UTF-8 bytes without splitting a character. */ - public static String boundedText(String value) { - return boundedText(value, MAX_MESSAGE_BYTES); - } - - /** Cuts {@code value} to {@code limit} UTF-8 bytes without splitting a character. */ - public static String boundedText(String value, int limit) { - String text = value == null ? "" : value; - int used = 0; - int index = 0; - while (index < text.length()) { - int codePoint = text.codePointAt(index); - int size = codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4; - if (used + size > limit) { - return text.substring(0, index); - } - used += size; - index += Character.charCount(codePoint); - } - return text; - } - - /** - * Methods reserved for the SDK. A plugin cannot install its own handler for - * these, or send them to the host: the lifecycle belongs to the host's - * session management, and {@code lithe/*} belongs to Lithe's typed contracts. - */ - public static boolean isReservedMethod(String method) { - return method.equals(INITIALIZE) - || method.equals(INITIALIZED) - || method.equals(SHUTDOWN) - || method.equals(EXIT) - || method.equals(CANCEL_REQUEST) - || method.startsWith("lithe/"); - } - - static int utf8Length(String value) { - return value.getBytes(StandardCharsets.UTF_8).length; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java deleted file mode 100644 index 24761e254a..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java +++ /dev/null @@ -1,198 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.ByteBuffer; -import java.nio.channels.FileChannel; -import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.nio.file.StandardOpenOption; -import java.nio.file.attribute.PosixFilePermissions; -import java.security.CodeSource; -import java.security.SecureRandom; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.regex.Pattern; - -/** - * Durable plugin state, in a directory the host gives the backend. - * - *

The host passes writable directories through the {@code ${stateDir}} and - * {@code ${workspaceStateDir}} launch variables; the installed package and the - * host's installation are read-only, because signatures and delta updates are - * computed over their exact bytes. Values are JSON; each key is one file - * replaced atomically, so a crash leaves either the old or the new value, - * never half of one. The envelope matches the Node.js SDK's, so either SDK - * reads what the other wrote. - */ -public final class StateStore { - /** Version of the envelope each value is stored in. */ - private static final int STORE_FORMAT = 1; - private static final Pattern KEY = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); - private static final SecureRandom RANDOM = new SecureRandom(); - - private final Path root; - - private StateStore(Path root) { - this.root = root; - } - - /** - * Opens a store in {@code directory}, creating the directory. Refuses a - * directory inside the plugin package, found as the nearest directory with - * a {@code plugin.json} above the JAR this class was loaded from. - * - * @param directory absolute path, normally the value of {@code ${workspaceStateDir}} - */ - public static StateStore open(Path directory) throws IOException { - return open(directory, findPackageDirectory()); - } - - /** - * Opens a store in {@code directory}, refusing one inside - * {@code packageDirectory}; null skips that check. - */ - public static StateStore open(Path directory, Path packageDirectory) throws IOException { - if (directory == null || !directory.isAbsolute()) { - throw new PluginSdkError("sdk.state.invalidDirectory", - "The state directory must be an absolute path passed by the host."); - } - // Checked before creating anything, so a refused path leaves the package untouched. - Path resolved = canonical(directory); - if (packageDirectory != null && resolved.startsWith(canonical(packageDirectory))) { - throw new PluginSdkError("sdk.state.readOnlyPackage", - "The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}."); - } - Files.createDirectories(resolved); - return new StateStore(resolved); - } - - /** The resolved directory, for a backend that keeps its own files next to the store. */ - public Path directory() { - return root; - } - - /** The stored value, or {@code fallback} when the key is missing or unreadable. */ - public Object read(String key, Object fallback) { - Path path = path(key); - if (!Files.isRegularFile(path)) { - return fallback; - } - try { - Object envelope = Json.parse(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); - if (!(envelope instanceof Map)) { - return fallback; - } - Map fields = (Map) envelope; - Object format = fields.get("litheStateStore"); - if (!(format instanceof Number) || ((Number) format).doubleValue() != STORE_FORMAT - || !fields.containsKey("value")) { - return fallback; - } - return fields.get("value"); - } catch (IOException | RuntimeException error) { - return fallback; - } - } - - /** Stores {@code value}, which must be JSON-encodable, replacing the previous one. */ - public void write(String key, Object value) throws IOException { - Path path = path(key); - Map envelope = new LinkedHashMap<>(); - envelope.put("litheStateStore", STORE_FORMAT); - envelope.put("value", value); - byte[] body; - try { - body = Json.stringify(envelope).getBytes(StandardCharsets.UTF_8); - } catch (Json.JsonException error) { - // Refused before touching the file, so the stored value stays. - throw new PluginSdkError("sdk.state.invalidValue", "The value for " + key + " is not JSON-encodable."); - } - Path temporary = root.resolve(key + ".json." + Long.toHexString(RANDOM.nextLong()) + ".tmp"); - try { - createPrivate(temporary); - try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { - ByteBuffer buffer = ByteBuffer.wrap(body); - while (buffer.hasRemaining()) { - channel.write(buffer); - } - channel.force(true); - } - try { - Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException error) { - Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); - } - } catch (IOException | RuntimeException error) { - Files.deleteIfExists(temporary); - throw error; - } - } - - /** Removes the key; a missing key is not an error. */ - public void delete(String key) throws IOException { - Files.deleteIfExists(path(key)); - } - - private Path path(String key) { - if (key == null || !KEY.matcher(key).matches() || key.endsWith(".tmp")) { - throw new PluginSdkError("sdk.state.invalidKey", "State key \"" + key - + "\" must be 1-128 letters, digits, \".\", \"_\" or \"-\", start with a letter or digit, and not end with \".tmp\"."); - } - return root.resolve(key + ".json"); - } - - /** Creates an empty file only the user can read, where the platform supports it. */ - private static void createPrivate(Path file) throws IOException { - try { - Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); - } catch (UnsupportedOperationException error) { - Files.createFile(file); - } catch (FileAlreadyExistsException error) { - throw new IOException("A temporary state file already exists: " + file.getFileName(), error); - } - } - - /** Resolves symbolic links of the longest existing prefix of {@code path}. */ - static Path canonical(Path path) throws IOException { - Path absolute = path.toAbsolutePath().normalize(); - Path existing = absolute; - while (existing != null && !Files.exists(existing)) { - existing = existing.getParent(); - } - if (existing == null) { - return absolute; - } - return existing.toRealPath().resolve(existing.relativize(absolute)); - } - - /** The nearest directory with a {@code plugin.json} above the JAR this class came from. */ - static Path findPackageDirectory() { - try { - CodeSource source = StateStore.class.getProtectionDomain().getCodeSource(); - URL location = source == null ? null : source.getLocation(); - if (location == null) { - return null; - } - Path current = Paths.get(location.toURI()).toAbsolutePath(); - if (Files.isRegularFile(current)) { - current = current.getParent(); - } - while (current != null) { - if (Files.isRegularFile(current.resolve("plugin.json"))) { - return current; - } - current = current.getParent(); - } - return null; - } catch (URISyntaxException | RuntimeException error) { - return null; - } - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java deleted file mode 100644 index db6e9dff0b..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java +++ /dev/null @@ -1,515 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import static io.github.litheidea.plugin.sdk.Fixtures.map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -/** Protocol behaviour of {@link Backend}; mirrors {@code sdk/node/test/backend.test.mjs}. */ -@Timeout(value = 30, unit = TimeUnit.SECONDS) -class BackendTest { - private static Backend functionBackend() { - return Backend.create("test-backend", "1.0.0") - .function("snapshot.refresh", (params, context) -> map("echoed", params)) - .function("install.prepare", (params, context) -> null); - } - - private static Map invoke(String functionId, String operationId, Object params) { - return map("functionID", functionId, "operationID", operationId, "params", params); - } - - @SuppressWarnings("unchecked") - private static Map object(Object value) { - return (Map) value; - } - - private static Object errorCode(Map response) { - Object error = response.get("error"); - return error == null ? null : object(error).get("code"); - } - - @Test - void initializeAnswersTheOfferedProtocolsWithSortedFunctions() { - Backend backend = functionBackend().onModel((params, context) -> map("projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - Map result = object(host.initialize().get("result")); - assertEquals( - map("litheBuild", map("version", 1L), - "litheFunctions", map("version", 1L, "functions", - Arrays.asList(map("id", "install.prepare"), map("id", "snapshot.refresh")))), - object(result.get("capabilities")).get("experimental")); - assertEquals(map("name", "test-backend", "version", "1.0.0"), result.get("serverInfo")); - assertTrue(backend.negotiated().functions()); - assertTrue(backend.negotiated().build()); - assertEquals(1, host.close()); - } - - @Test - void aHostThatOffersNothingGetsNoLitheCapabilities() { - TestHost quiet = new TestHost(functionBackend()); - assertEquals(map(), object(object(quiet.initialize(map()).get("result")).get("capabilities")).get("experimental")); - quiet.close(); - - // No build handler: litheBuild is never claimed. Version 2 only: no functions. - TestHost other = new TestHost(functionBackend()); - Map answer = object(other.initialize(map( - "litheBuild", map("versions", Arrays.asList(1L)), - "litheFunctions", map("versions", Arrays.asList(2L)))).get("result")); - assertEquals(map(), object(answer.get("capabilities")).get("experimental")); - other.close(); - } - - @Test - void functionIdsFollowTheSharedContractIncludingTheFixtureCases() throws IOException { - for (String id : Arrays.asList("snapshot.refresh", "install.prepare", "a", "x1.y2Z")) { - assertTrue(Protocol.isFunctionId(id), id); - } - StringBuilder long129 = new StringBuilder(); - for (int index = 0; index < 129; index++) { - long129.append('a'); - } - for (String id : Arrays.asList("", "Not-Valid", "snapshot..refresh", "1abc", "a_b", "é", long129.toString(), "a.")) { - assertFalse(Protocol.isFunctionId(id), id); - } - // Every ID the fixture accepts is accepted locally, and the fixture's - // malformed ID refused, so the SDK never declares what Core rejects. - Map fixture = object(Json.parse(new String(Files.readAllBytes( - Paths.get("../../../shared/contracts/plugins/v1/fixtures/function-protocol.json")), StandardCharsets.UTF_8))); - for (Object entry : (List) fixture.get("negotiation")) { - Map testCase = object(entry); - List ids = new ArrayList<>(); - Object experimental = object(testCase.get("capabilities")).get("experimental"); - Object functions = experimental == null ? null : object(experimental).get("litheFunctions"); - Object declared = functions == null ? null : object(functions).get("functions"); - if (declared != null) { - for (Object function : (List) declared) { - ids.add((String) object(function).get("id")); - } - } - String name = (String) testCase.get("name"); - if (name.contains("malformed")) { - assertTrue(ids.stream().anyMatch(id -> !Protocol.isFunctionId(id)), name); - } else if (object(testCase.get("expected")).get("functions") != null) { - assertTrue(ids.stream().allMatch(Protocol::isFunctionId), name); - } - } - PluginSdkError invalid = assertThrows(PluginSdkError.class, - () -> Backend.create(null, null).function("Bad-ID", (params, context) -> null)); - assertEquals("sdk.function.invalidID", invalid.code()); - PluginSdkError duplicate = assertThrows(PluginSdkError.class, () -> Backend.create(null, null) - .function("a", (params, context) -> null) - .function("a", (params, context) -> null)); - assertEquals("sdk.function.duplicate", duplicate.code()); - } - - @Test - void theLifecycleAndTheLitheNamespaceStayReservedForTheSdk() { - Backend backend = Backend.create(null, null); - for (String method : Arrays.asList("initialize", "shutdown", "exit", "$/cancelRequest", "lithe/build/model", "lithe/anything")) { - assertThrows(PluginSdkError.class, () -> backend.onRequest(method, (params, context) -> null), method); - } - PluginSdkError reserved = assertThrows(PluginSdkError.class, () -> Backend.create(null, null, - map("experimental", map("litheFunctions", map("version", 1))))); - assertEquals("sdk.capabilities.reserved", reserved.code()); - TestHost host = new TestHost(backend); - host.initialize(); - // A backend never asks the host to dispatch a domain operation or a function. - for (String method : Arrays.asList("lithe/function/invoke", "lithe/build/model", "lithe/host/process.run")) { - PluginSdkError error = assertThrows(PluginSdkError.class, () -> backend.sendRequest(method, map()), method); - assertEquals("sdk.method.reserved", error.code()); - } - host.close(); - } - - @Test - void requestsBeforeInitializeAreRefusedWithServerNotInitialized() { - TestHost host = new TestHost(functionBackend()); - Map response = host.request("lithe/function/invoke", invoke("snapshot.refresh", "op", map())); - assertEquals(-32002L, errorCode(response)); - host.close(); - } - - @Test - void anInvocationReturnsItsResultAndNullStaysNull() { - TestHost host = new TestHost(functionBackend()); - host.initialize(); - Map response = host.request("lithe/function/invoke", - invoke("snapshot.refresh", "op-1", map("project", "app"))); - assertEquals(map("echoed", map("project", "app")), response.get("result")); - Map empty = host.request("lithe/function/invoke", invoke("install.prepare", "op-2", null)); - assertTrue(empty.containsKey("result")); - assertNull(empty.get("result")); - Map unknown = host.request("lithe/function/invoke", invoke("missing", "op-3", null)); - assertEquals(-32602L, errorCode(unknown)); - host.close(); - } - - @Test - void progressEchoesTheOperationIsBoundedAndStopsOnceTheCallEnded() { - CountDownLatch release = new CountDownLatch(1); - AtomicReference late = new AtomicReference<>(); - StringBuilder longMessage = new StringBuilder(); - for (int index = 0; index < 2000; index++) { - longMessage.append('x'); - } - Backend backend = Backend.create(null, null).function("long.task", (params, context) -> { - context.progress(longMessage.toString(), 140.4); - context.progress("half", 49.6); - late.set(context); - Fixtures.await(release, "the test to release the call"); - return "done"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("long.task", "wire-7", null), 1001L); - Map first = object(host.waitFor( - message -> "lithe/function/progress".equals(message.get("method")), "progress").get("params")); - assertEquals("wire-7", first.get("operationID")); - assertEquals(1024, ((String) first.get("message")).length()); - assertEquals(100L, first.get("percentage")); - Map second = object(host.waitFor(message -> message.get("params") instanceof Map - && "half".equals(object(message.get("params")).get("message")), "the second report").get("params")); - assertEquals(50L, second.get("percentage")); - release.countDown(); - assertEquals("done", Fixtures.await(response, "the response").get("result")); - late.get().progress("too late", null); - host.close(); - assertTrue(host.messages().stream().noneMatch(message -> message.get("params") instanceof Map - && "too late".equals(object(message.get("params")).get("message")))); - } - - @Test - void aPluginErrorCarriesItsCodeAndOtherFailuresHideTheirDetails() { - Backend backend = Backend.create(null, null) - .function("locked", (params, context) -> { - throw new PluginError("maven.snapshotLocked", "Snapshot is locked."); - }) - .function("crash", (params, context) -> { - throw new IOException("No such file: /Users/someone/secret"); - }); - TestHost host = new TestHost(backend); - host.initialize(); - Map locked = host.request("lithe/function/invoke", invoke("locked", "a", null)); - assertEquals(map("code", -32000L, "message", "Snapshot is locked.", "data", map("code", "maven.snapshotLocked")), - locked.get("error")); - Map crash = object(host.request("lithe/function/invoke", invoke("crash", "b", null)).get("error")); - assertEquals(-32603L, crash.get("code")); - assertFalse(((String) crash.get("message")).contains("/Users/")); - assertFalse(crash.containsKey("data")); - assertThrows(IllegalArgumentException.class, () -> new PluginError("bad code!", "x")); - host.close(); - } - - @Test - void aFailingCancellationListenerNeitherSkipsTheOthersNorTheReply() { - CountDownLatch laterListenerRan = new CountDownLatch(1); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch cancelled = new CountDownLatch(1); - Backend backend = Backend.create(null, null) - .function("listener.settles", (params, context) -> { - context.cancellation().onCancel(() -> { - throw new IllegalStateException("clean-up failed"); - }); - context.cancellation().onCancel(laterListenerRan::countDown); - return "ok"; - }) - .function("listener.cancelled", (params, context) -> { - context.cancellation().onCancel(() -> { - throw new IllegalStateException("clean-up failed"); - }); - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the cancellation"); - context.throwIfCancelled(); - return "not reached"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - // The call ending runs the listeners before the reply. - assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "s", null)).get("result")); - Fixtures.await(laterListenerRan, "the listener after the failing one"); - // $/cancelRequest runs them on the read loop, which must keep reading. - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("listener.cancelled", "c", null), 42L); - Fixtures.await(started, "the handler to start"); - host.notify("$/cancelRequest", map("id", 42L)); - assertEquals(-32800L, errorCode(Fixtures.await(response, "the cancelled call"))); - assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "t", null)).get("result")); - host.close(); - } - - @Test - void cancelRequestCancelsTheHandlerAndAnswersRequestCancelled() { - CountDownLatch started = new CountDownLatch(1); - CountDownLatch cancelled = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("wait.forever", (params, context) -> { - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the cancellation"); - context.throwIfCancelled(); - return "not reached"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("wait.forever", "c", null), 41L); - Fixtures.await(started, "the handler to start"); - host.notify("$/cancelRequest", map("id", 41L)); - assertEquals(-32800L, errorCode(Fixtures.await(response, "the response"))); - host.close(); - } - - @Test - void registerAndUnregisterFunctionGoThroughTheHostAllOrNothing() throws Exception { - Backend backend = Backend.create(null, null); - TestHost host = new TestHost(backend); - host.initialize(); - - CompletableFuture registering = backend.registerFunction("late.one", (params, context) -> "late"); - Map register = host.waitFor( - message -> "lithe/function/register".equals(message.get("method")), "the registration"); - assertEquals(map("functions", Arrays.asList(map("id", "late.one"))), register.get("params")); - host.send(map("id", register.get("id"), "result", null)); - Fixtures.await(registering, "the registration to settle"); - assertEquals("late", host.request("lithe/function/invoke", invoke("late.one", "d", null)).get("result")); - - // A refused registration leaves no handler behind. - CompletableFuture refused = backend.registerFunction("other.one", (params, context) -> 0); - Map second = host.waitFor(message -> "lithe/function/register".equals(message.get("method")) - && object(message.get("params")).toString().contains("other.one"), "the second registration"); - host.send(map("id", second.get("id"), "error", - map("code", -32602L, "message", "No.", "data", map("code", "plugin.function.invalidRegistration")))); - ExecutionException failure = assertThrows(ExecutionException.class, - () -> refused.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); - assertEquals("plugin.function.invalidRegistration", ((PluginSdkError) unwrap(failure)).code()); - assertEquals(Arrays.asList("late.one"), backend.functionIds()); - - CompletableFuture unregistering = backend.unregisterFunction("late.one"); - Map unregister = host.waitFor( - message -> "lithe/function/unregister".equals(message.get("method")), "the withdrawal"); - assertEquals(map("ids", Arrays.asList("late.one")), unregister.get("params")); - host.send(map("id", unregister.get("id"), "result", null)); - Fixtures.await(unregistering, "the withdrawal to settle"); - assertEquals(Collections.emptyList(), backend.functionIds()); - host.close(); - } - - @Test - void buildHandlersGetTheCanonicalFormatStampedAndAWrongFormatFails() { - Backend backend = Backend.create(null, null) - .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", params.get("root"))))) - .onDependencies((params, context) -> map("format", "litheBuildModel", "projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(); - Map model = host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")); - assertEquals(map("projects", Arrays.asList(map("name", "app", "path", ".")), "format", "litheBuildModel", "version", 1L), - model.get("result")); - Map dependencies = host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")); - assertEquals(-32603L, errorCode(dependencies)); - host.close(); - } - - @Test - void aBuildResultCannotReplaceTheVersionTheSdkStamps() { - Backend backend = Backend.create(null, null) - .onModel((params, context) -> map("version", 2, "projects", Collections.emptyList())) - .onDependencies((params, context) -> map("version", 1, "dependencies", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(); - assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - assertEquals(map("version", 1L, "dependencies", Collections.emptyList(), "format", "litheDependencies"), - host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")).get("result")); - host.close(); - } - - @Test - void aResultJsonCannotEncodeOrOneOverOneMebibyteFailsAndTheSessionContinues() { - Map cycle = map(); - cycle.put("self", cycle); - char[] large = new char[1 << 20]; - Arrays.fill(large, 'x'); - Backend backend = Backend.create(null, null) - .function("result.object", (params, context) -> new Object()) - .function("result.cycle", (params, context) -> cycle) - .function("result.nan", (params, context) -> Double.NaN) - .function("result.large", (params, context) -> new String(large)) - .function("result.fine", (params, context) -> map("ok", true)) - .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", ".", "size", new Object())))); - TestHost host = new TestHost(backend); - host.initialize(); - int operation = 0; - for (String functionId : Arrays.asList("result.object", "result.cycle", "result.nan", "result.large")) { - Map response = host.request("lithe/function/invoke", invoke(functionId, "op-" + operation++, null)); - assertEquals(-32603L, errorCode(response), functionId); - } - assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - assertEquals(map("ok", true), host.request("lithe/function/invoke", invoke("result.fine", "op-last", null)).get("result")); - assertEquals(1, host.close()); - } - - @Test - void buildRequestsAreRefusedWhenTheHostDidNotNegotiateLitheBuild() { - Backend backend = Backend.create(null, null).onModel((params, context) -> map("projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(map("litheFunctions", map("versions", Arrays.asList(1L)))); - assertEquals(-32601L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - host.close(); - } - - @Test - void shutdownCancelsRunningCallsAndRunsHooksAndExitAfterShutdownIsZero() { - CountDownLatch started = new CountDownLatch(1); - List order = Collections.synchronizedList(new ArrayList<>()); - Backend backend = Backend.create(null, null) - .function("wait.forever", (params, context) -> { - CountDownLatch cancelled = new CountDownLatch(1); - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the shutdown to cancel the call"); - context.throwIfCancelled(); - return null; - }) - .onShutdown(() -> order.add("hook")); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> call = host.requestAsync("lithe/function/invoke", - invoke("wait.forever", "e", null), 2001L); - Fixtures.await(started, "the handler to start"); - Map shutdown = host.request("shutdown", null); - assertTrue(shutdown.containsKey("result")); - assertNull(shutdown.get("result")); - assertEquals(-32800L, errorCode(Fixtures.await(call, "the cancelled call"))); - assertEquals(Arrays.asList("hook"), order); - assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("wait.forever", "f", null)))); - host.notify("exit", null); - assertEquals(0, host.exitCode()); - } - - @Test - void exitWithoutShutdownOrAClosedInputEndsWithCodeOne() { - TestHost host = new TestHost(Backend.create(null, null)); - host.initialize(); - host.notify("exit", null); - assertEquals(1, host.exitCode()); - assertEquals(1, new TestHost(Backend.create(null, null)).close()); - } - - @Test - void aMalformedBodyIsAnsweredWithAParseErrorAndTheSessionContinues() { - TestHost host = new TestHost(functionBackend()); - byte[] body = "{not json".getBytes(StandardCharsets.UTF_8); - host.sendBytes(("Content-Length: " + body.length + "\r\n\r\n{not json").getBytes(StandardCharsets.UTF_8)); - Map parseError = host.waitFor(message -> message.get("error") instanceof Map - && Long.valueOf(-32700L).equals(object(message.get("error")).get("code")), "the parse error"); - assertTrue(parseError.containsKey("id")); - assertNull(parseError.get("id")); - assertNotNull(host.initialize().get("result")); - host.close(); - } - - @Test - void aJsonValueThatIsNotAMessageObjectIsRefusedAndTheSessionContinues() { - TestHost host = new TestHost(functionBackend()); - host.sendBytes(Framing.encode(Arrays.asList(1, 2))); - host.waitFor(message -> message.get("error") instanceof Map - && Long.valueOf(-32600L).equals(object(message.get("error")).get("code")), "the refusal"); - assertNotNull(host.initialize().get("result")); - host.close(); - } - - @Test - void registeringFromAnInitializeHookJoinsTheInitialListWithoutARequest() { - Backend backend = Backend.create(null, null).function("a.one", (params, context) -> 1); - backend.onInitialize((params, negotiated) -> { - backend.registerFunction("b.two", (arguments, context) -> 2); - backend.unregisterFunction("a.one"); - }); - TestHost host = new TestHost(backend); - Map experimental = object(object(object(host.initialize().get("result")).get("capabilities")).get("experimental")); - assertEquals(Arrays.asList(map("id", "b.two")), object(experimental.get("litheFunctions")).get("functions")); - assertTrue(host.messages().stream().noneMatch(message -> message.get("method") instanceof String - && ((String) message.get("method")).startsWith("lithe/function/"))); - host.close(); - } - - @Test - void runtimeRegistrationNeedsTheNegotiatedProtocol() { - Backend backend = Backend.create(null, null); - TestHost host = new TestHost(backend); - host.initialize(map()); - ExecutionException failure = assertThrows(ExecutionException.class, () -> backend - .registerFunction("late.one", (params, context) -> 1).get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); - assertEquals("sdk.functions.unavailable", ((PluginSdkError) unwrap(failure)).code()); - host.close(); - } - - @Test - void aShutdownDuringASlowInitializeHookKeepsTheSessionClosed() { - CountDownLatch hookStarted = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - Backend backend = Backend.create(null, null).onInitialize((params, negotiated) -> { - hookStarted.countDown(); - Fixtures.await(release, "the test to release initialize"); - }); - TestHost host = new TestHost(backend); - CompletableFuture> initialize = host.requestAsync("initialize", - map("capabilities", map()), 3001L); - Fixtures.await(hookStarted, "the initialize hook"); - assertNull(host.request("shutdown", null).get("error")); - release.countDown(); - assertEquals(-32600L, errorCode(Fixtures.await(initialize, "the initialize answer"))); - assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("x", "y", null)))); - host.notify("exit", null); - assertEquals(0, host.exitCode()); - } - - @Test - void notificationsReachTheirHandlerOnlyOnceReady() { - CountDownLatch received = new CountDownLatch(1); - AtomicBoolean early = new AtomicBoolean(); - Backend backend = Backend.create(null, null).onNotification("workspace/didChangeConfiguration", params -> { - if (!(params instanceof Map)) { - early.set(true); - } - received.countDown(); - }); - TestHost host = new TestHost(backend); - host.notify("workspace/didChangeConfiguration", "before"); - host.initialize(); - host.notify("workspace/didChangeConfiguration", map("settings", map())); - Fixtures.await(received, "the notification"); - assertFalse(early.get()); - host.close(); - } - - private static Throwable unwrap(Throwable error) { - Throwable current = error; - while ((current instanceof ExecutionException || current instanceof CompletionException) && current.getCause() != null) { - current = current.getCause(); - } - return current; - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java deleted file mode 100644 index c7f13e43db..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.BooleanSupplier; - -/** Small helpers shared by the SDK tests. */ -final class Fixtures { - private Fixtures() {} - - /** A map from alternating keys and values, in order. */ - static Map map(Object... entries) { - Map map = new LinkedHashMap<>(); - for (int index = 0; index < entries.length; index += 2) { - map.put((String) entries[index], entries[index + 1]); - } - return map; - } - - /** The future's value within {@link TestHost#WAIT_MILLIS}. */ - static T await(CompletableFuture future, String description) { - try { - return future.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS); - } catch (TimeoutException error) { - throw new AssertionError("Timed out waiting for " + description + ".", error); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } catch (ExecutionException error) { - throw new AssertionError(description + " failed.", error.getCause()); - } - } - - /** Waits for {@code latch} within {@link TestHost#WAIT_MILLIS}. */ - static void await(CountDownLatch latch, String description) { - try { - if (!latch.await(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { - throw new AssertionError("Timed out waiting for " + description + "."); - } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } - } - - /** - * Bounded poll of an OS-level fact, such as a process having exited, that - * offers the test no notification channel. - */ - static void eventually(BooleanSupplier check, String description) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(TestHost.WAIT_MILLIS); - while (!check.getAsBoolean()) { - if (System.nanoTime() > deadline) { - throw new AssertionError("Timed out waiting for " + description + "."); - } - try { - // test-stability: allow(real-sleep) reason: the polled fact is a separate OS process's state with no notification channel; the deadline above bounds the loop. - Thread.sleep(10); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } - } - } - - /** True while the POSIX process {@code pid} exists. */ - static boolean isAlive(long pid) { - try { - Process probe = new ProcessBuilder("kill", "-0", Long.toString(pid)).start(); - if (!probe.waitFor(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { - probe.destroyForcibly(); - throw new AssertionError("kill -0 did not answer."); - } - return probe.exitValue() == 0; - } catch (IOException error) { - throw new UncheckedIOException(error); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError(error); - } - } - - /** The PID a tool wrote to {@code file}, once it is there. */ - static long awaitPid(Path file) { - eventually(() -> { - try { - return Files.isRegularFile(file) && !new String(Files.readAllBytes(file)).trim().isEmpty(); - } catch (IOException error) { - return false; - } - }, "a PID in " + file.getFileName()); - try { - return Long.parseLong(new String(Files.readAllBytes(file)).trim()); - } catch (IOException error) { - throw new UncheckedIOException(error); - } - } - - static void deleteTree(Path root) { - if (!Files.exists(root)) { - return; - } - try { - Files.walkFileTree(root, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException { - Files.delete(directory); - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException error) { - throw new UncheckedIOException(error); - } - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java deleted file mode 100644 index ce9771eb9c..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java +++ /dev/null @@ -1,370 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import static io.github.litheidea.plugin.sdk.Fixtures.map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import java.io.IOException; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.api.condition.DisabledOnOs; -import org.junit.jupiter.api.condition.OS; - -/** Framing, JSON, tools, and state; mirrors {@code sdk/node/test/support.test.mjs}. */ -@Timeout(value = 30, unit = TimeUnit.SECONDS) -class SupportTest { - private Path directory; - - @BeforeEach - void createDirectory() throws IOException { - directory = Files.createTempDirectory("lithe-sdk-").toRealPath(); - } - - @AfterEach - void removeDirectory() { - Processes.killAll(); - Fixtures.deleteTree(directory); - } - - private static List shell(String script) { - return Arrays.asList("/bin/sh", "-c", script); - } - - /** The SDK gives each tool its own process group through the system perl. */ - private static boolean hasProcessGroups() { - return new java.io.File("/usr/bin/perl").canExecute() || new java.io.File("/bin/perl").canExecute(); - } - - private static boolean hasProcessHandle() { - try { - Class.forName("java.lang.ProcessHandle"); - return true; - } catch (ClassNotFoundException error) { - return false; - } - } - - @Test - void framesSplitAcrossChunksOrSeveralInOneChunkDecodeInOrder() { - Framing.Decoder decoder = new Framing.Decoder(); - byte[] first = Framing.encode(map("id", 1, "text", "é✓")); - byte[] second = Framing.encode(map("id", 2)); - byte[] bytes = Arrays.copyOf(first, first.length + second.length); - System.arraycopy(second, 0, bytes, first.length, second.length); - List messages = new ArrayList<>(); - for (int index = 0; index < bytes.length; index += 3) { - messages.addAll(decoder.push(bytes, index, Math.min(3, bytes.length - index))); - } - assertEquals(Arrays.asList(map("id", 1L, "text", "é✓"), map("id", 2L)), messages); - byte[] oversized = "Content-Length: 999999999999\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(oversized, 0, oversized.length)); - byte[] missing = "X-Other: 1\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(missing, 0, missing.length)); - } - - @Test - void aHeaderOverEightKibibytesIsRefusedAlsoWhenTheWholeFrameArrivesAtOnce() { - StringBuilder header = new StringBuilder("Content-Length: 2\r\nX-Padding: "); - while (header.length() <= 8200) { - header.append('x'); - } - byte[] frame = (header + "\r\n\r\n{}").getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(frame, 0, frame.length)); - } - - @Test - void jsonRoundTripsProtocolValuesAndRefusesWhatItCannotRepresent() { - String text = "{\"a\":[1,-2.5,true,false,null,\"\\u00e9\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1e3}"; - Map value = Json.object(Json.parse(text)); - assertEquals(Arrays.asList(1L, -2.5, true, false, null, "é\n\""), value.get("a")); - assertEquals(new BigInteger("123456789012345678901234567890"), value.get("big")); - assertEquals(1000.0, value.get("e")); - // Integral doubles print without a fraction, as JavaScript prints them. - String encoded = Json.stringify(value); - assertEquals("{\"a\":[1,-2.5,true,false,null,\"é\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1000}", encoded); - assertEquals(encoded, Json.stringify(Json.parse(encoded))); - assertEquals("\"\\ud800x\"", Json.stringify("\ud800x")); - assertEquals("[1,2.5]", Json.stringify(Arrays.asList(1.0, 2.5))); - for (String malformed : Arrays.asList("", "{", "[1,]", "{\"a\" 1}", "01", "1.", "\"\u0001\"", "tru", "{} x")) { - assertThrows(Json.JsonException.class, () -> Json.parse(malformed), malformed); - } - Map numericKeys = new HashMap<>(); - numericKeys.put(1, "x"); - assertThrows(Json.JsonException.class, () -> Json.stringify(numericKeys)); - assertThrows(Json.JsonException.class, () -> Json.stringify(Double.POSITIVE_INFINITY)); - StringBuilder deep = new StringBuilder(); - for (int index = 0; index < 600; index++) { - deep.append('['); - } - assertThrows(Json.JsonException.class, () -> Json.parse(deep.toString())); - } - - @Test - void boundedTextCutsOnCharacterBoundaries() { - assertEquals("ab", Protocol.boundedText("abc", 2)); - assertEquals("é", Protocol.boundedText("éé", 3)); - assertEquals("", Protocol.boundedText("😀", 3)); - assertEquals("", Protocol.boundedText(null)); - assertTrue(Protocol.isPluginErrorCode("maven.snapshotLocked")); - assertFalse(Protocol.isPluginErrorCode("bad code")); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void runCapturesOutputAndReturnsANonZeroExit() throws IOException { - Processes.Result result = Processes.run(shell("printf out; printf err >&2; exit 3")); - assertEquals(3, result.exitCode()); - assertEquals("out", result.stdout()); - assertEquals("err", result.stderr()); - assertFalse(result.truncated()); - Processes.Result limited = Processes.run(shell("printf xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"), - new Processes.Options().maxOutputBytes(10)); - assertEquals("xxxxxxxxxx", limited.stdout()); - assertTrue(limited.truncated()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void streamedOutputKeepsACharacterSplitAcrossReadsWhole() throws IOException { - // 3-byte characters never align with the 8 KiB reads, so reads split them. - String expected = String.join("", Collections.nCopies(50_000, "✓")); - Path file = directory.resolve("checks.txt"); - Files.write(file, expected.getBytes(StandardCharsets.UTF_8)); - StringBuilder streamed = new StringBuilder(); - Processes.Result result = Processes.run(Arrays.asList("cat", file.toString()), - new Processes.Options().onStdout(streamed::append)); - assertEquals(expected, streamed.toString()); - assertEquals(expected, result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aReplacedEnvironmentStillCarriesTheHostProcessMarkers() throws IOException { - Map environment = new HashMap<>(); - environment.put("PATH", System.getenv("PATH")); - environment.put("LITHE_PROCESS_SCOPE", "forged"); - environment.put("OWN", "kept"); - Processes.Result result = Processes.run(shell("printf '%s|%s' \"${LITHE_PROCESS_SCOPE-unset}\" \"$OWN\""), - new Processes.Options().environment(environment)); - String inherited = System.getenv("LITHE_PROCESS_SCOPE"); - assertEquals((inherited == null ? "unset" : inherited) + "|kept", result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void cancellingACallEndsTheToolTreeItStarted() { - assumeTrue(hasProcessGroups() || hasProcessHandle(), - "without perl, Java 8 ends only the direct child; the host scope ends the rest"); - Path grandchildPid = directory.resolve("grandchild.pid"); - // The child starts a grandchild that ignores SIGTERM, so only the forced - // stop can end it. - String grandchild = "trap '' TERM; echo $$ > '" + grandchildPid + "'; while :; do sleep 1; done"; - String child = "/bin/sh -c \"" + grandchild.replace("$", "\\$") + "\" & wait"; - CountDownLatch started = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { - started.countDown(); - return context.run(shell(child), new Processes.Options().killGraceMillis(50)).exitCode(); - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - map("functionID", "tool.run", "operationID", "g"), 7L); - Fixtures.await(started, "the tool to start"); - long pid = Fixtures.awaitPid(grandchildPid); - host.notify("$/cancelRequest", map("id", 7L)); - Map answer = Fixtures.await(response, "the cancelled call"); - assertEquals(-32800L, ((Map) answer.get("error")).get("code")); - Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the grandchild to be stopped"); - Fixtures.eventually(() -> Processes.runningCount() == 0, "the registry to drain"); - host.close(); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aToolThatExitsTakesItsBackgroundWorkersButNotAnotherCallsTool() throws Exception { - assumeTrue(hasProcessGroups(), "without perl the tool has no process group of its own"); - Path workerPid = directory.resolve("worker.pid"); - Path otherPid = directory.resolve("other.pid"); - Cancellation other = new Cancellation(); - CompletableFuture otherCall = CompletableFuture.supplyAsync(() -> { - try { - return Processes.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600"), - new Processes.Options().cancellation(other).killGraceMillis(50)); - } catch (IOException | CancelledException error) { - return error; - } - }); - long otherTool = Fixtures.awaitPid(otherPid); - // The tool succeeds at once and leaves a worker that holds no pipe. - Processes.Result result = Processes.run(shell( - "sleep 600 >/dev/null 2>&1 & echo $! > '" + workerPid + "'; exit 0")); - assertEquals(0, result.exitCode()); - long worker = Fixtures.awaitPid(workerPid); - Fixtures.eventually(() -> !Fixtures.isAlive(worker), "the left worker to be killed"); - assertTrue(Fixtures.isAlive(otherTool), "a concurrent call's tool keeps running"); - other.cancel("done"); - assertTrue(Fixtures.await(otherCall, "the other call") instanceof CancelledException); - Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aMissingExecutableFailsToStartAndPerlOptionsReachOnlyTheTool() throws IOException { - IOException missing = assertThrows(IOException.class, - () -> Processes.run(Arrays.asList(directory.resolve("missing").toString()))); - assertTrue(missing.getMessage().contains("missing"), missing.getMessage()); - Map environment = new HashMap<>(); - environment.put("PATH", System.getenv("PATH")); - // Perl would refuse to start with this; the tool only sees it. - environment.put("PERL5OPT", "-MLithe::Missing::Module"); - Processes.Result result = Processes.run(shell("printf %s \"$PERL5OPT\""), - new Processes.Options().environment(environment)); - assertEquals(0, result.exitCode(), result.stderr()); - assertEquals("-MLithe::Missing::Module", result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void closingTheSessionEndsToolsStillRunning() { - Path childPid = directory.resolve("child.pid"); - CountDownLatch started = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { - started.countDown(); - // `exec` keeps the PID: the tool itself ignores SIGTERM. - return context.run(shell("trap '' TERM; echo $$ > '" + childPid + "'; exec sleep 600")).exitCode(); - }); - TestHost host = new TestHost(backend); - host.initialize(); - // No reply is awaited: the session ends while the call is still running. - host.send(map("id", 8L, "method", "lithe/function/invoke", - "params", map("functionID", "tool.run", "operationID", "h"))); - Fixtures.await(started, "the tool to start"); - long pid = Fixtures.awaitPid(childPid); - assertEquals(1, host.close()); - Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the tool to be killed"); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aCallThatSettlesStopsTheToolsItsHandlerLeftRunningAndOnlyThose() { - Path leftPid = directory.resolve("left.pid"); - Path otherPid = directory.resolve("other.pid"); - CountDownLatch otherStarted = new CountDownLatch(1); - CountDownLatch releaseOther = new CountDownLatch(1); - Backend backend = Backend.create(null, null) - .function("tool.leave", (params, context) -> { - Thread background = new Thread(() -> { - try { - context.run(shell("echo $$ > '" + leftPid + "'; exec sleep 600")); - } catch (IOException | CancelledException error) { - // Stopped with its call, as intended. - } - }); - background.setDaemon(true); - background.start(); - Fixtures.awaitPid(leftPid); - return "returned"; - }) - .function("tool.other", (params, context) -> { - Thread background = new Thread(() -> { - try { - context.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600")); - } catch (IOException | CancelledException error) { - // Ends with its own call. - } - }); - background.setDaemon(true); - background.start(); - otherStarted.countDown(); - Fixtures.await(releaseOther, "the test to release the other call"); - return null; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> other = host.requestAsync("lithe/function/invoke", - map("functionID", "tool.other", "operationID", "o"), 11L); - Fixtures.await(otherStarted, "the other call"); - long otherTool = Fixtures.awaitPid(otherPid); - assertEquals("returned", host.request("lithe/function/invoke", - map("functionID", "tool.leave", "operationID", "l")).get("result")); - long leftTool = Fixtures.awaitPid(leftPid); - Fixtures.eventually(() -> !Fixtures.isAlive(leftTool), "the left tool to be stopped"); - assertTrue(Fixtures.isAlive(otherTool), "the other call's tool keeps running"); - releaseOther.countDown(); - Fixtures.await(other, "the other call"); - Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); - host.close(); - } - - @Test - void theStateStoreWritesAtomicallyAndSurvivesReopening() throws IOException { - Path state = directory.resolve("workspace-state"); - StateStore store = StateStore.open(state, null); - assertEquals("none", store.read("snapshot", "none")); - store.write("snapshot", map("generation", 1)); - store.write("snapshot", map("generation", 2)); - assertEquals(map("generation", 2L), StateStore.open(state, null).read("snapshot", null)); - try (Stream files = Files.list(state)) { - assertEquals(Arrays.asList("snapshot.json"), - files.map(path -> path.getFileName().toString()).collect(Collectors.toList())); - } - // The same envelope as the Node.js SDK, so either reads the other's state. - assertEquals("{\"litheStateStore\":1,\"value\":{\"generation\":2}}", - new String(Files.readAllBytes(state.resolve("snapshot.json")), StandardCharsets.UTF_8)); - Files.write(state.resolve("broken.json"), "{".getBytes(StandardCharsets.UTF_8)); - assertNull(store.read("broken", null)); - store.delete("snapshot"); - store.delete("snapshot"); - assertNull(store.read("snapshot", null)); - for (String key : Arrays.asList("", "../escape", "a/b", "x.tmp", ".hidden")) { - PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write(key, 1), key); - assertEquals("sdk.state.invalidKey", error.code()); - } - } - - @Test - void theStateStoreRefusesAValueJsonCannotEncodeAndKeepsTheStoredOne() throws IOException { - StateStore store = StateStore.open(directory.resolve("state"), null); - store.write("kept", map("ok", true)); - PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write("kept", new Object())); - assertEquals("sdk.state.invalidValue", error.code()); - assertEquals(map("ok", true), store.read("kept", null)); - } - - @Test - void theStateStoreRefusesTheReadOnlyPluginPackage() throws IOException { - Path packageDirectory = directory.resolve("package"); - Files.createDirectories(packageDirectory); - Files.write(packageDirectory.resolve("plugin.json"), "{}".getBytes(StandardCharsets.UTF_8)); - PluginSdkError refused = assertThrows(PluginSdkError.class, - () -> StateStore.open(packageDirectory.resolve("state"), packageDirectory)); - assertEquals("sdk.state.readOnlyPackage", refused.code()); - // Refused before anything was created inside the package. - assertFalse(Files.exists(packageDirectory.resolve("state"))); - PluginSdkError relative = assertThrows(PluginSdkError.class, - () -> StateStore.open(java.nio.file.Paths.get("relative/state"), null)); - assertEquals("sdk.state.invalidDirectory", relative.code()); - StateStore.open(directory.resolve("state"), packageDirectory); - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java deleted file mode 100644 index 8fc2c5cd31..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java +++ /dev/null @@ -1,215 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; - -/** - * A host for SDK tests: it writes framed messages to an in-memory backend and - * decodes the answers, with a bounded wait on each. - */ -final class TestHost { - /** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ - static final long WAIT_MILLIS = 5000; - - private final QueueInput input = new QueueInput(); - private final List> messages = new ArrayList<>(); - private final ByteArrayOutputStream errors = new ByteArrayOutputStream(); - private final CompletableFuture done = new CompletableFuture<>(); - private int nextId = 1; - - TestHost(Backend backend) { - Framing.Decoder decoder = new Framing.Decoder(); - OutputStream output = new OutputStream() { - @Override - public void write(int value) { - write(new byte[] {(byte) value}, 0, 1); - } - - @Override - public void write(byte[] bytes, int offset, int length) { - for (Object message : decoder.push(bytes, offset, length)) { - synchronized (messages) { - messages.add(Json.object(message)); - messages.notifyAll(); - } - } - } - }; - PrintStream errorOutput = new PrintStream(errors, true); - Thread thread = new Thread(() -> { - try { - done.complete(backend.listen(input, output, errorOutput)); - } catch (Throwable error) { - done.completeExceptionally(error); - } - }, "test-host-backend"); - thread.setDaemon(true); - thread.start(); - } - - /** Sends one message; {@code jsonrpc} is added. */ - void send(Map message) { - Map framed = new LinkedHashMap<>(); - framed.put("jsonrpc", "2.0"); - framed.putAll(message); - input.push(Framing.encode(framed)); - } - - /** Writes raw bytes, for framing tests. */ - void sendBytes(byte[] bytes) { - input.push(bytes); - } - - /** Sends a request and waits for the backend's response. */ - Map request(String method, Object params) { - return requestAsync(method, params, nextId()).join(); - } - - Map request(String method, Object params, Object id) { - return requestAsync(method, params, id).join(); - } - - /** Sends a request now and resolves with the response once it arrives. */ - CompletableFuture> requestAsync(String method, Object params, Object id) { - send(Fixtures.map("id", id, "method", method, "params", params)); - return CompletableFuture.supplyAsync(() -> waitFor( - message -> id.equals(message.get("id")) && !message.containsKey("method"), - "the response to " + method)); - } - - void notify(String method, Object params) { - send(Fixtures.map("method", method, "params", params)); - } - - Map initialize() { - return initialize(Fixtures.map( - "litheBuild", Fixtures.map("versions", Arrays.asList(1)), - "litheFunctions", Fixtures.map("versions", Arrays.asList(1)))); - } - - Map initialize(Map experimental) { - Map response = request("initialize", Fixtures.map( - "processId", null, - "rootUri", "file:///workspace", - "capabilities", Fixtures.map("experimental", experimental))); - notify("initialized", Fixtures.map()); - return response; - } - - /** The first message, past or future, that {@code matches}. */ - Map waitFor(Predicate> matches, String description) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WAIT_MILLIS); - synchronized (messages) { - for (;;) { - for (Map message : messages) { - if (matches.test(message)) { - return message; - } - } - long remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); - if (remaining <= 0) { - throw new AssertionError("Timed out waiting for " + description + "; messages: " + messages - + "; stderr: " + errors()); - } - try { - messages.wait(remaining); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description, error); - } - } - } - } - - List> messages() { - synchronized (messages) { - return new ArrayList<>(messages); - } - } - - String errors() { - return new String(errors.toByteArray(), StandardCharsets.UTF_8); - } - - /** The backend's exit code once the session ended. */ - int exitCode() { - return Fixtures.await(done, "the backend to exit"); - } - - /** Closes the input and returns the backend's exit code. */ - int close() { - input.close(); - return exitCode(); - } - - private Object nextId() { - synchronized (this) { - return (long) nextId++; - } - } - - /** An input stream fed by the test; closing it is the host closing stdin. */ - private static final class QueueInput extends InputStream { - private static final byte[] END = new byte[0]; - private final LinkedBlockingQueue chunks = new LinkedBlockingQueue<>(); - private byte[] current = new byte[0]; - private int position; - private boolean ended; - - void push(byte[] bytes) { - chunks.add(bytes.clone()); - } - - @Override - public void close() { - chunks.add(END); - } - - @Override - public int read() { - byte[] one = new byte[1]; - int count = read(one, 0, 1); - return count < 0 ? -1 : one[0] & 0xff; - } - - @Override - public int read(byte[] buffer, int offset, int length) { - if (ended) { - return -1; - } - while (position >= current.length) { - byte[] next; - try { - // The backend's reader blocks here as it blocks on stdin; the - // test bounds every wait on the backend's answers instead. - next = chunks.take(); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - return -1; - } - if (next == END) { - ended = true; - return -1; - } - current = next; - position = 0; - } - int count = Math.min(length, current.length - position); - System.arraycopy(current, position, buffer, offset, count); - position += count; - return count; - } - } -} diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml deleted file mode 100644 index d4f11ee084..0000000000 --- a/sdk/java/pom.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - 4.0.0 - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - pom - - - plugin-sdk - examples/sample-build - - - - UTF-8 - 8 - 5.14.0 - - 2026-10-20T00:00:00Z - - - - - - org.junit.jupiter - junit-jupiter - ${junit.version} - test - - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.14.0 - - - -Xlint:all,-options - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.4 - - - 300 - - - - org.apache.maven.plugins - maven-jar-plugin - 3.4.2 - - - org.apache.maven.plugins - maven-shade-plugin - 3.6.0 - - - - - diff --git a/sdk/node/README.md b/sdk/node/README.md deleted file mode 100644 index f11e666fcb..0000000000 --- a/sdk/node/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Lithe plugin backend SDK for Node.js - -`@lithe/plugin-sdk` is the official way to write a plugin backend in -JavaScript: the process a plugin starts through a `runtime` launch -(`{ "kind": "runtime", "tool": "node", … }`). It speaks the LSP base protocol -on stdio and implements Lithe's two backend protocols from -[plugin contract v1](../../shared/contracts/plugins/v1/README.md): - -- **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` - requests, for a build system whose `model` or `dependencies` source is - `provider`. -- **`litheFunctions` 1**: plugin functions, which are plugin-private actions - that Lithe invokes for the same plugin, with progress, cancellation, and - registration at runtime. - -The SDK owns the wire format, negotiation, cancellation, process cleanup, and -error mapping. Your code supplies only handlers. Node 20.3 or newer is -required; declare it as the runtime's `minimumVersion`. - -## A minimal backend - -```js -import { createBackend, PluginError } from './vendor/lithe-plugin-sdk/src/index.mjs'; - -const backend = createBackend({ name: 'example-backend', version: '1.0.0' }); - -backend.build.model(async ({ root }) => ({ - buildSystemID: 'example', - root, - projects: [{ name: 'app', path: root }], -})); - -backend.function('snapshot.refresh', async (params, context) => { - context.progress({ message: 'Refreshing', percentage: 10 }); - const result = await context.run('example-tool', ['refresh'], { cwd: params.root }); - if (result.exitCode !== 0) throw new PluginError('example.refreshFailed', 'The refresh failed.'); - return { refreshedAt: Date.now() }; -}); - -backend.listen(); -``` - -[`examples/sample-build`](examples/sample-build) is a complete plugin with a -build provider, functions, persistent state, a function registered at runtime, -and cancellation. Rust Core's -`rust/lithe-core/tests/plugin_sdk_node.rs` drives it through a real Core -session. - -## Packaging - -The SDK is not published to a registry yet. Copy `src/` into your plugin -package (for example as `backend/vendor/lithe-plugin-sdk/src/`) and import it -by relative path. A plugin package is installed read-only, has no -`node_modules` install step, and must run offline, so everything the backend -imports has to be inside the package. The SDK has no dependencies. - -## API - -| API | Purpose | -| --- | --- | -| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | -| `backend.build.model(handler)`, `backend.build.dependencies(handler)` | Implements the typed build requests. The SDK stamps `format` and `version`; a handler returning another format or a version other than 1 fails. Core validates the document. `litheBuild` is answered only when a handler exists. | -| `backend.function(id, handler)` | Declares a function offered in `initialize`. | -| `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | -| `backend.onInitialize(hook)`, `backend.onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | -| `backend.onRequest(method, handler)`, `backend.onNotification(method, handler)` | Ordinary LSP methods, such as `textDocument/hover` for a language server. Lifecycle and `lithe/*` methods are reserved. | -| `backend.sendRequest`, `backend.sendNotification` | Ordinary LSP messages to the host; `lithe/*` methods are reserved. | -| `backend.log(...)` | Writes a line to stderr, which the host keeps in its log. | -| `backend.listen()` | Serves stdio until `exit` or the end of input. | -| `PluginError(code, message)` | A failure meant for your own UI. | -| `runProcess(command, args, options)` | Runs a process without a shell, as one cancellable process tree. | -| `openStateStore(directory)` | A small atomic JSON store in a host-provided directory. | - -Every handler receives a context: - -| Member | Meaning | -| --- | --- | -| `signal` | Aborted when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends; also once the handler has settled, so work it left running stops. | -| `throwIfCancelled()` | Throws the cancellation if `signal` is aborted. | -| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. | -| `log(...)` | Same as `backend.log`. | -| `functionID`, `operationID` | Functions only. `operationID` is opaque; never derive anything from it. | -| `progress({ message?, percentage? })` | Functions only. The message is cut to 1024 bytes and the percentage clamped to 0–100; reports after the call ends are dropped. | - -## Results and errors - -| Handler outcome | What Lithe sees | -| --- | --- | -| Returns a value (including `null`) | `result.value` | -| Throws `PluginError(code, message)` | `plugin.function.failed` with your `message` and `pluginErrorCode`. The code is for your plugin's own UI; Lithe never branches on it. | -| Throws anything else | `plugin.function.failed` with a generic message, so paths and stack traces never reach the UI. The real error goes to stderr. | -| Ends because of `signal` | `requestCancelled` (or Lithe's timeout/revocation outcome) | - -A result must serialize to at most 1 MiB of JSON. A value JSON cannot encode -(a `BigInt`, a cycle) or a larger one fails like any other unexpected error and -is logged to stderr; the session keeps running. A function ID is dot-separated -lowerCamelCase segments (`snapshot.refresh`), at most 128 bytes, and a session -holds at most 256 functions. A `PluginError` code matches -`[A-Za-z][A-Za-z0-9._-]*`, at most 64 bytes. - -## Rules that keep Lithe in charge - -The SDK leaves your plugin free to define its own functions, params, results, -UI, and state, but these rules keep Lithe's workflows, permissions, and -lifecycle unified. The SDK enforces the ones it can. - -1. **Domain capabilities stay typed.** Build models and dependency trees go - through `backend.build.*`, never through a function, so every host and IDE - workflow reads one contract. Functions are for actions only your plugin - understands. -2. **No calls back into Lithe's dispatch.** A backend never sends - `lithe/function/invoke` or `lithe/build/*`; Lithe would route it back to the - same process, which would then wait for itself. Lithe refuses these with - `plugin.function.recursiveCall`, and the SDK refuses to send any `lithe/*` - method. -3. **stdout belongs to the protocol.** When serving stdio, the SDK moves - `console.log`, `console.info`, and `console.debug` to stderr. Never write to - `process.stdout` yourself. -4. **The package is read-only.** Keep caches, snapshots, and logs under the - directories Lithe passes through launch templates (`${stateDir}`, - `${workspaceStateDir}`). `openStateStore` refuses any directory inside the - plugin package. Writing to the package breaks code signing and update - deltas. -5. **Work ends with its call.** Start processes with `context.run`, and check - `signal` in long loops. On cancellation the SDK stops the process tree - politely, then forcibly. When the handler returns or throws, tools of that - call still running (an unawaited `context.run`, the siblings of a failed - `Promise.all`) are stopped the same way. A tool that exits on its own takes - the workers it left in its process group with it. On `shutdown`, `exit`, or - the end of input the SDK ends every process it started. If the backend - itself is killed, no JavaScript runs; ending what it left is the host's - job, not the SDK's. Lithe finds those processes by the markers it puts in - the backend's environment, so `runProcess` carries them over even when - `env` replaces the environment. -6. **Errors stay presentable.** Throw `PluginError` for failures the user - should see; every other exception is reported generically. - -## Testing - -```bash -npm test --prefix sdk/node -``` - -The tests use a bounded in-memory host (`test/harness.mjs`), which plugin -authors can copy to test their own backends without starting Lithe. diff --git a/sdk/node/examples/sample-build/backend/main.mjs b/sdk/node/examples/sample-build/backend/main.mjs deleted file mode 100644 index 0d3a8267a8..0000000000 --- a/sdk/node/examples/sample-build/backend/main.mjs +++ /dev/null @@ -1,149 +0,0 @@ -// Sample Lithe plugin backend built on the official Node.js SDK. -// -// It is the build provider of the fictional "sample" build system, whose -// workspace file `lithe-sample.json` lists projects: -// -// { "projects": [{ "name": "app", "path": "app", -// "build": ["node", "build.mjs"], -// "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] }] } -// -// It shows the three things a backend does: -// - answers Lithe's typed build requests (`lithe/build/model`, -// `lithe/build/dependencies`), whose results Lithe itself understands; -// - offers plugin functions for its own UI: `workspace.build` runs a -// multi-step build (prepare, run each project's tool, record a snapshot), -// and `snapshot.read` returns the last snapshot, also after a restart; -// - registers `snapshot.clear` at runtime once there is a snapshot to clear. -// -// A packaged plugin vendors the SDK inside its package, for example as -// `backend/vendor/lithe-plugin-sdk/`, and imports it from there. - -import { readFile } from 'node:fs/promises'; -import { isAbsolute, join, normalize, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createBackend, openStateStore, PluginError } from '../../../src/index.mjs'; - -const backend = createBackend({ name: 'sample-build', version: '0.1.0' }); -let workspaceRoot; -let store; - -backend.onInitialize(async ({ params }) => { - // Hosts may send a directory URI with a trailing slash (macOS does); - // `resolve` drops it so `inside` compares like paths. - workspaceRoot = resolve(fileURLToPath(params.rootUri)); - // `--state ${workspaceStateDir}` in plugin.json: the host's writable - // directory for this workspace. The package itself is read-only. - store = openStateStore(argument('--state')); - if (store.read('snapshot', null)) offerClear(); -}); - -backend.build.model(async ({ root }) => { - const config = await readConfig(root); - return { - projects: config.projects.map(({ name, path }) => ({ name, path })), - }; -}); - -backend.build.dependencies(async ({ root, project }) => { - const config = await readConfig(root); - const projects = project ? config.projects.filter(({ path }) => path === project.path) : config.projects; - return { - dependencies: projects.flatMap(({ dependencies = [] }) => - dependencies.map(({ name, version }) => ({ name, version, children: [] })), - ), - }; -}); - -backend.function('workspace.build', async ({ projects: selected }, context) => { - // Step 1: prepare. Read the workspace file and resolve the selection. - context.progress({ message: 'Preparing', percentage: 0 }); - const config = await readConfig('.'); - const projects = selectProjects(config, selected); - - // Step 2: run each project's tool. `context.run` ends the whole process - // tree when Lithe cancels the call, so a cancelled build leaves nothing behind. - const results = []; - for (const [index, project] of projects.entries()) { - context.progress({ message: `Building ${project.name}`, percentage: (100 * index) / projects.length }); - const [tool, ...args] = project.build; - const { exitCode } = await context.run(tool === 'node' ? process.execPath : tool, args, { - cwd: inside(workspaceRoot, project.path), - onStdout: (text) => context.log(`[${project.name}] ${text.trimEnd()}`), - }); - results.push({ name: project.name, exitCode }); - if (exitCode !== 0) { - record(results, 'failed'); - throw new PluginError('sample.buildFailed', `${project.name} failed with exit code ${exitCode}.`); - } - } - - // Step 3: record the outcome, so `snapshot.read` returns it after a restart. - const snapshot = record(results, 'succeeded'); - context.progress({ message: 'Done', percentage: 100 }); - return snapshot; -}); - -backend.function('snapshot.read', () => store.read('snapshot', null)); - -// Reads the latest snapshot when the build ends, not when it starts: two -// overlapping builds then get consecutive generations instead of the same one. -// The read and the write run without an `await` between them. -function record(projects, status) { - const previous = store.read('snapshot', null); - const snapshot = { generation: (previous?.generation ?? 0) + 1, status, projects }; - store.write('snapshot', snapshot); - offerClear(); - return snapshot; -} - -function offerClear() { - if (backend.functionIDs.includes('snapshot.clear')) return; - backend - .registerFunction('snapshot.clear', () => { - store.delete('snapshot'); - return null; - }) - .catch((error) => backend.log('snapshot.clear stays unavailable:', error.message)); -} - -/** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ -function selectProjects(config, selected) { - if (selected === undefined || selected === null) return config.projects; - if (!Array.isArray(selected) || selected.length === 0 || !selected.every((name) => typeof name === 'string')) { - throw new PluginError('sample.selection', '"projects" must be a non-empty list of project names.'); - } - const unknown = selected.filter((name) => !config.projects.some((project) => project.name === name)); - if (unknown.length > 0) { - throw new PluginError('sample.unknownProject', `Unknown project: ${unknown.join(', ')}.`); - } - return config.projects.filter(({ name }) => selected.includes(name)); -} - -async function readConfig(root) { - let config; - try { - config = JSON.parse(await readFile(join(inside(workspaceRoot, root), 'lithe-sample.json'), 'utf8')); - } catch { - throw new PluginError('sample.config', 'lithe-sample.json is missing or is not valid JSON.'); - } - if (!Array.isArray(config.projects)) { - throw new PluginError('sample.config', 'lithe-sample.json needs a "projects" list.'); - } - return config; -} - -/** `relative` resolved inside `base`; workspace files never name paths outside it. */ -function inside(base, relative) { - const path = normalize(join(base, relative)); - if (isAbsolute(relative) || (path !== base && !path.startsWith(base + sep))) { - throw new PluginError('sample.path', `${relative} is outside the workspace.`); - } - return path; -} - -function argument(name) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -await backend.listen(); diff --git a/sdk/node/examples/sample-build/plugin.json b/sdk/node/examples/sample-build/plugin.json deleted file mode 100644 index 57f2aac87a..0000000000 --- a/sdk/node/examples/sample-build/plugin.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "schemaVersion": 2, - "id": "org.example.sample-build", - "displayName": "Sample Build", - "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1 }, - "vendor": { - "id": "org.example", - "displayName": "Example", - "signatureRequirement": "publisherPackage" - }, - "permissions": ["languageServer.launch", "process.build"], - "activationEvents": ["onBuildSystem:sample"], - "languages": [], - "buildSystems": [ - { - "id": "sample", - "displayName": "Sample", - "detection": { "markers": ["lithe-sample.json"], "nesting": "outermost" }, - "invocation": { - "executables": [{ "kind": "command", "command": "node" }] - }, - "provider": { - "launch": { - "kind": "runtime", - "tool": "node", - "minimumVersion": "20.3", - "entry": "backend/main.mjs" - }, - "arguments": ["--state", "${workspaceStateDir}"], - "executesProjectCode": true, - "mayUseNetwork": false - }, - "model": { "source": "provider" }, - "tasks": [ - { - "id": "build", - "displayName": "Build", - "group": "build", - "scopes": ["workspace"], - "arguments": ["--version"] - } - ], - "dependencies": { "source": "provider", "scopes": ["workspace", "project"] }, - "sync": { "watch": ["lithe-sample.json"] } - } - ] -} diff --git a/sdk/node/package.json b/sdk/node/package.json deleted file mode 100644 index cdf782f21a..0000000000 --- a/sdk/node/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@lithe/plugin-sdk", - "version": "0.1.0", - "description": "Official SDK for Lithe plugin backends: plugin functions and build providers over the LSP base protocol.", - "license": "Apache-2.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20.3" - }, - "exports": { - ".": { - "types": "./src/index.d.ts", - "default": "./src/index.mjs" - } - }, - "files": [ - "src", - "README.md" - ], - "scripts": { - "test": "node --test test/*.test.mjs" - } -} diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs deleted file mode 100644 index e9eb1ebc37..0000000000 --- a/sdk/node/src/backend.mjs +++ /dev/null @@ -1,647 +0,0 @@ -// The plugin backend session: one process the host starts from the plugin's -// manifest and talks to over stdio with the LSP base protocol. The SDK owns -// everything Lithe manages uniformly -- the lifecycle, protocol negotiation, -// cancellation, error codes, and the `lithe/*` namespace -- and leaves the -// plugin free to decide what its handlers do. -// -// Contract: shared/contracts/plugins/v1/README.md ("Build providers" and -// "Plugin functions"). - -import { format } from 'node:util'; -import { encodeFrame, FrameDecoder, FramingError, MALFORMED } from './framing.mjs'; -import { PluginSdkError, toResponseError } from './errors.mjs'; -import { killAllProcesses, runProcess } from './process.mjs'; -import { - BUILD_VERSION, - boundedText, - ERROR_CODES, - FUNCTIONS_VERSION, - isFunctionId, - isReservedMethod, - LIMITS, - METHODS, -} from './protocol.mjs'; - -/** Canonical result format of each typed build request. */ -const BUILD_FORMATS = Object.freeze({ - [METHODS.buildModel]: 'litheBuildModel', - [METHODS.buildDependencies]: 'litheDependencies', -}); - -/** - * Creates a backend. Declare handlers, then call `listen()`. - * - * @param {object} [options] - * @param {string} [options.name] Reported as `serverInfo.name` and used as the log prefix. - * @param {string} [options.version] Reported as `serverInfo.version`. - * @param {object} [options.capabilities] Standard LSP server capabilities, for a - * backend that is also a language server. `experimental.litheBuild` and - * `experimental.litheFunctions` are negotiated by the SDK and cannot be set here. - */ -export function createBackend(options = {}) { - return new Backend(options); -} - -class Backend { - #name; - #version; - #capabilities; - #functions = new Map(); - #buildHandlers = new Map(); - #requestHandlers = new Map(); - #notificationHandlers = new Map(); - #initializeHooks = []; - #shutdownHooks = []; - /** `idle` → `listening` → `initializing` → `ready` → `shuttingDown` → `exited`. */ - #phase = 'idle'; - #negotiated = Object.freeze({ functions: false, build: false }); - #initializeParams = null; - /** Host requests being handled, by JSON-RPC ID. */ - #incoming = new Map(); - /** Requests this backend sent to the host, by JSON-RPC ID. */ - #outgoing = new Map(); - #nextRequestId = 1; - /** Serializes register/unregister so the host sees them in call order. */ - #registrations = Promise.resolve(); - #output = null; - #errorOutput = process.stderr; - #finish = null; - - /** Typed build-provider handlers (`lithe/build/*`). */ - build; - - constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { - const experimental = capabilities.experimental ?? {}; - if ('litheBuild' in experimental || 'litheFunctions' in experimental) { - throw new PluginSdkError( - 'sdk.capabilities.reserved', - 'litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead.', - ); - } - this.#name = String(name); - this.#version = version === undefined ? undefined : String(version); - this.#capabilities = capabilities; - this.build = Object.freeze({ - /** Answers `lithe/build/model` with a `litheBuildModel` document. */ - model: (handler) => this.#declareBuild(METHODS.buildModel, handler), - /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ - dependencies: (handler) => this.#declareBuild(METHODS.buildDependencies, handler), - }); - } - - /** Protocols the host agreed to in `initialize`; both false before it. */ - get negotiated() { - return this.#negotiated; - } - - /** The host's `initialize` params, or null before it arrives. */ - get initializeParams() { - return this.#initializeParams; - } - - /** IDs of the functions this backend currently serves, sorted. */ - get functionIDs() { - return [...this.#functions.keys()].sort(); - } - - /** - * Declares a plugin function offered from the start, in the `initialize` - * answer. To add one later, use `registerFunction`. - * - * @param {string} id Dot-separated lowerCamelCase segments, such as `snapshot.refresh`. - * @param {(params: object, context: object) => unknown} handler - */ - function(id, handler) { - if (!this.#beforeAnswer()) { - throw new PluginSdkError('sdk.function.lateDeclaration', 'After initialize, add functions with registerFunction().'); - } - this.#checkNewFunction(id, handler); - this.#functions.set(id, handler); - return this; - } - - /** Handles a standard LSP request, for a backend that is also a language server. */ - onRequest(method, handler) { - this.#declareHandler(this.#requestHandlers, method, handler); - return this; - } - - /** Handles a standard LSP notification. */ - onNotification(method, handler) { - this.#declareHandler(this.#notificationHandlers, method, handler); - return this; - } - - /** Runs before the `initialize` answer, with `{ params, negotiated }`. */ - onInitialize(hook) { - this.#initializeHooks.push(hook); - return this; - } - - /** Runs on `shutdown`, after running calls were cancelled. */ - onShutdown(hook) { - this.#shutdownHooks.push(hook); - return this; - } - - /** - * Adds a function. Before the `initialize` answer (including from an - * `onInitialize` hook) it joins the initial list; afterwards the host must - * accept it first. - */ - registerFunction(id, handler) { - if (this.#beforeAnswer()) { - return settle(() => { - this.#checkNewFunction(id, handler); - this.#functions.set(id, handler); - }); - } - return this.#queueRegistration(async () => { - this.#requireFunctions(); - this.#checkNewFunction(id, handler); - // Installed before the request: the host may invoke it as soon as its - // registry changed, before this backend reads the reply. - this.#functions.set(id, handler); - try { - await this.#request(METHODS.functionRegister, { functions: [{ id }] }); - } catch (error) { - this.#functions.delete(id); - throw error; - } - }); - } - - /** Withdraws a function. The host ends its running calls as revoked. */ - unregisterFunction(id) { - if (this.#beforeAnswer()) { - return settle(() => { - if (!this.#functions.delete(id)) { - throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); - } - }); - } - return this.#queueRegistration(async () => { - this.#requireFunctions(); - if (!this.#functions.has(id)) { - throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); - } - await this.#request(METHODS.functionUnregister, { ids: [id] }); - this.#functions.delete(id); - for (const call of this.#incoming.values()) { - if (call.functionID === id) call.controller.abort(); - } - }); - } - - /** Sends a standard LSP request to the host. `lithe/*` methods are reserved. */ - sendRequest(method, params) { - this.#checkOutgoing(method); - return this.#request(method, params); - } - - /** Sends a standard LSP notification to the host. `lithe/*` methods are reserved. */ - sendNotification(method, params) { - this.#checkOutgoing(method); - this.#write({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) }); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - log(...parts) { - this.#errorOutput.write(`[${this.#name}] ${format(...parts)}\n`); - } - - /** - * Serves the host until it sends `exit` or closes the input stream, and - * resolves with the process exit code. With the default `exit`, the process - * then exits with that code. - * - * @param {object} [io] - * @param {NodeJS.ReadableStream} [io.input] Defaults to stdin. - * @param {NodeJS.WritableStream} [io.output] Defaults to stdout. - * @param {NodeJS.WritableStream} [io.errorOutput] Defaults to stderr. - * @param {((code: number) => void) | null} [io.exit] Defaults to `process.exit`; null only resolves. - */ - listen({ input = process.stdin, output = process.stdout, errorOutput = process.stderr, exit = (code) => process.exit(code) } = {}) { - if (this.#phase !== 'idle') { - throw new PluginSdkError('sdk.listen.repeated', 'A backend listens once.'); - } - this.#phase = 'listening'; - this.#output = output; - this.#errorOutput = errorOutput; - if (output === process.stdout) redirectConsole(errorOutput); - - return new Promise((resolve) => { - this.#finish = (code) => { - if (this.#phase === 'exited') return; - this.#phase = 'exited'; - this.#abortAll(); - killAllProcesses(); - for (const pending of this.#outgoing.values()) { - pending.reject(new PluginSdkError('sdk.session.closed', 'The session ended.')); - } - this.#outgoing.clear(); - input.off?.('data', onData); - resolve(code); - exit?.(code); - }; - const decoder = new FrameDecoder(); - const onData = (chunk) => { - let messages; - try { - messages = decoder.push(chunk); - } catch (error) { - if (!(error instanceof FramingError)) throw error; - this.log('Ending the session:', error.message); - this.#finish(1); - return; - } - for (const message of messages) this.#dispatch(message); - }; - input.on('data', onData); - input.once('end', () => this.#finish(this.#phase === 'shuttingDown' ? 0 : 1)); - input.once('error', () => this.#finish(1)); - }); - } - - #dispatch(message) { - if (message === MALFORMED) { - this.#respond(null, undefined, { code: ERROR_CODES.parseError, message: 'The message is not valid JSON.' }); - return; - } - // Valid JSON that is not a message object, such as `null` or a batch. - if (typeof message !== 'object' || message === null || Array.isArray(message)) { - this.#respond(null, undefined, { code: ERROR_CODES.invalidRequest, message: 'The message is not a JSON-RPC object.' }); - return; - } - const hasID = message.id !== undefined && message.id !== null; - if (typeof message.method !== 'string') { - if (hasID) this.#settleOutgoing(message); - return; - } - if (hasID) { - this.#handleRequest(message); - } else { - this.#handleNotification(message); - } - } - - #handleRequest({ id, method, params }) { - if (method === METHODS.initialize) { - void this.#initialize(id, params ?? {}); - return; - } - if (method === METHODS.shutdown) { - void this.#shutdown(id); - return; - } - if (this.#phase === 'listening' || this.#phase === 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.serverNotInitialized, message: 'The backend is not initialized.' }); - return; - } - if (this.#phase !== 'ready') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The backend is shutting down.' }); - return; - } - if (method === METHODS.functionInvoke) { - this.#invokeFunction(id, params ?? {}); - return; - } - const build = this.#buildHandlers.get(method); - if (build && this.#negotiated.build) { - this.#run(id, null, (context) => build(params ?? {}, context), (result) => buildResult(method, result)); - return; - } - const handler = this.#requestHandlers.get(method); - if (handler) { - this.#run(id, null, (context) => handler(params, context), (result) => result ?? null); - return; - } - this.#respond(id, undefined, { code: ERROR_CODES.methodNotFound, message: `Unsupported method: ${method}` }); - } - - #handleNotification({ method, params }) { - if (method === METHODS.exit) { - this.#finish(this.#phase === 'shuttingDown' ? 0 : 1); - return; - } - if (method === METHODS.cancelRequest) { - this.#incoming.get(requestKey(params?.id))?.controller.abort(); - return; - } - if (this.#phase !== 'ready' || method === METHODS.initialized) return; - const handler = this.#notificationHandlers.get(method); - if (!handler) return; - Promise.resolve() - .then(() => handler(params)) - .catch((error) => this.log(`Notification ${method} failed:`, error)); - } - - async #initialize(id, params) { - if (this.#phase !== 'listening') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'initialize was already received.' }); - return; - } - this.#phase = 'initializing'; - this.#initializeParams = params; - const offered = params.capabilities?.experimental ?? {}; - const offers = (entry, version) => Array.isArray(entry?.versions) && entry.versions.includes(version); - this.#negotiated = Object.freeze({ - functions: offers(offered.litheFunctions, FUNCTIONS_VERSION), - build: this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION), - }); - try { - for (const hook of this.#initializeHooks) { - await hook({ params, negotiated: this.#negotiated }); - } - } catch (error) { - this.log('initialize failed:', error); - if (this.#phase === 'initializing') this.#phase = 'listening'; - this.#respond(id, undefined, toResponseError(error)); - return; - } - // A shutdown or exit that arrived while a hook ran ended the session; - // finishing initialize must not reopen it. - if (this.#phase !== 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The session ended during initialize.' }); - return; - } - if (!this.#negotiated.functions && this.#functions.size > 0) { - this.log('The host did not offer litheFunctions version 1; functions stay unavailable.'); - } - const experimental = { ...(this.#capabilities.experimental ?? {}) }; - if (this.#negotiated.build) experimental.litheBuild = { version: BUILD_VERSION }; - if (this.#negotiated.functions) { - experimental.litheFunctions = { - version: FUNCTIONS_VERSION, - functions: this.functionIDs.map((functionID) => ({ id: functionID })), - }; - } - this.#phase = 'ready'; - this.#respond(id, { - capabilities: { ...this.#capabilities, experimental }, - serverInfo: { name: this.#name, ...(this.#version ? { version: this.#version } : {}) }, - }); - } - - async #shutdown(id) { - if (this.#phase !== 'ready' && this.#phase !== 'listening' && this.#phase !== 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'shutdown was already received.' }); - return; - } - this.#phase = 'shuttingDown'; - this.#abortAll(); - for (const hook of this.#shutdownHooks) { - try { - await hook(); - } catch (error) { - this.log('A shutdown hook failed:', error); - } - } - killAllProcesses(); - this.#respond(id, null); - } - - #invokeFunction(id, { functionID, operationID, params }) { - const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; - if (!handler) { - this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: `Function ${functionID} is not registered.` }); - return; - } - let reporting = true; - const progress = ({ message, percentage } = {}) => { - if (!reporting) return; - const report = { operationID }; - if (message !== undefined) report.message = boundedText(message); - if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); - this.#write({ jsonrpc: '2.0', method: METHODS.functionProgress, params: report }); - }; - this.#run( - id, - functionID, - (context) => handler(params ?? {}, { ...context, functionID, operationID, progress }), - functionResult, - () => { - reporting = false; - }, - ); - } - - /** Runs one handler with its own cancellation and answers exactly once. */ - #run(id, functionID, invoke, shape, onSettled) { - const controller = new AbortController(); - const key = requestKey(id); - this.#incoming.set(key, { controller, functionID }); - const { signal } = controller; - const context = { - signal, - throwIfCancelled: () => signal.throwIfAborted(), - run: (command, args, options = {}) => { - const running = runProcess(command, args, { - ...options, - signal: options.signal ? AbortSignal.any([signal, options.signal]) : signal, - }); - // A tool the handler did not await is stopped when the call ends; its - // rejection then must not end the backend as an unhandled rejection. - running.catch(() => {}); - return running; - }, - log: (...parts) => this.log(...parts), - }; - Promise.resolve() - .then(() => invoke(context)) - .then( - (value) => { - signal.throwIfAborted(); - return { result: shape(value) }; - }, - (error) => { - if (!signal.aborted) this.log(`${functionID ? `Function ${functionID}` : 'A request'} failed:`, error); - return { error: toResponseError(error, signal) }; - }, - ) - .catch((error) => { - // Shaping the result failed, for example a build result in another format. - if (!signal.aborted) this.log('The result could not be sent:', error); - return { error: toResponseError(error, signal) }; - }) - .then(({ result, error }) => { - onSettled?.(); - this.#incoming.delete(key); - // Work belongs to its call: tools still running when the handler - // settled, such as the siblings of a failed `Promise.all`, end now. - // Other calls have their own controllers and keep running. - controller.abort(new PluginSdkError('sdk.call.ended', 'The call ended.')); - this.#respond(id, result, error); - }); - } - - #declareBuild(method, handler) { - if (this.#phase !== 'idle') { - throw new PluginSdkError('sdk.build.lateDeclaration', 'Declare build handlers before listen().'); - } - if (typeof handler !== 'function') throw new TypeError('A build handler must be a function.'); - this.#buildHandlers.set(method, handler); - return this; - } - - #declareHandler(handlers, method, handler) { - if (typeof method !== 'string' || isReservedMethod(method)) { - throw new PluginSdkError('sdk.method.reserved', `${method} is managed by the SDK and Lithe.`); - } - if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); - handlers.set(method, handler); - } - - #checkNewFunction(id, handler) { - if (!isFunctionId(id)) { - throw new PluginSdkError( - 'sdk.function.invalidID', - `Function ID ${JSON.stringify(id)} must be dot-separated lowerCamelCase segments of at most 128 bytes.`, - ); - } - if (typeof handler !== 'function') throw new TypeError('A function handler must be a function.'); - if (this.#functions.has(id)) { - throw new PluginSdkError('sdk.function.duplicate', `Function ${id} is already declared.`); - } - if (this.#functions.size >= LIMITS.maxFunctions) { - throw new PluginSdkError('sdk.function.limit', `A backend serves at most ${LIMITS.maxFunctions} functions.`); - } - } - - /** True until the `initialize` answer fixes the initial function list. */ - #beforeAnswer() { - return this.#phase === 'idle' || this.#phase === 'listening' || this.#phase === 'initializing'; - } - - #requireFunctions() { - if (this.#phase !== 'ready' || !this.#negotiated.functions) { - throw new PluginSdkError('sdk.functions.unavailable', 'The host has not negotiated plugin functions.'); - } - } - - #queueRegistration(change) { - const next = this.#registrations.then(change); - this.#registrations = next.catch(() => {}); - return next; - } - - #checkOutgoing(method) { - if (typeof method !== 'string' || isReservedMethod(method)) { - throw new PluginSdkError( - 'sdk.method.reserved', - `${method} is reserved. A backend never asks the host to run a domain operation or a function.`, - ); - } - if (this.#phase !== 'ready') { - throw new PluginSdkError('sdk.session.notReady', 'The session is not initialized.'); - } - } - - #request(method, params) { - const id = `sdk-${this.#nextRequestId++}`; - return new Promise((resolve, reject) => { - this.#outgoing.set(id, { resolve, reject, method }); - this.#write({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }); - }); - } - - #settleOutgoing({ id, result, error }) { - const pending = this.#outgoing.get(id); - if (!pending) return; - this.#outgoing.delete(id); - if (error) { - const failure = new PluginSdkError(error.data?.code ?? 'sdk.host.error', error.message ?? `${pending.method} failed.`); - failure.rpcCode = error.code; - pending.reject(failure); - } else { - pending.resolve(result ?? null); - } - } - - #abortAll() { - for (const call of this.#incoming.values()) call.controller.abort(); - } - - #respond(id, result, error) { - if (this.#phase === 'exited') return; - try { - this.#write(error ? { jsonrpc: '2.0', id, error } : { jsonrpc: '2.0', id, result: result ?? null }); - } catch (failure) { - // A result JSON cannot encode, such as a BigInt or a cycle, must not - // become an unhandled rejection that ends the whole session. - this.log('The result could not be encoded:', failure); - this.#write({ jsonrpc: '2.0', id, error: toResponseError(failure) }); - } - } - - #write(message) { - if (!this.#output || this.#phase === 'exited') return; - this.#output.write(encodeFrame(message)); - } -} - -/** Runs `change` now and reports its outcome as a promise. */ -function settle(change) { - try { - change(); - return Promise.resolve(); - } catch (error) { - return Promise.reject(error); - } -} - -/** The JSON-RPC ID as a map key; numbers and strings never collide. */ -function requestKey(id) { - return `${typeof id}:${id}`; -} - -/** - * Stamps the canonical format on a build result, so a handler can return the - * plain document. A result in another format is a backend bug: Core would - * refuse it as `plugin.build.invalidOutput`, so it fails here with a clear log. - */ -function buildResult(method, result) { - const expected = BUILD_FORMATS[method]; - if (result === null || typeof result !== 'object' || Array.isArray(result)) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a ${expected} object.`); - } - if (result.format !== undefined && result.format !== expected) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); - } - if (result.version !== undefined && result.version !== 1) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not 1.`); - } - return { ...result, format: expected, version: 1 }; -} - -/** - * A function result as the JSON value Core accepts. A value JSON cannot - * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a - * log line, instead of breaking the response frame or being refused by Core. - */ -function functionResult(result) { - let encoded; - try { - encoded = JSON.stringify(result ?? null); - } catch (error) { - throw new PluginSdkError('sdk.function.invalidResult', `The result is not JSON: ${error.message}`); - } - if (encoded === undefined) { - throw new PluginSdkError('sdk.function.invalidResult', 'The result is not a JSON value.'); - } - if (Buffer.byteLength(encoded) > LIMITS.maxResultBytes) { - throw new PluginSdkError('sdk.function.invalidResult', `The result exceeds ${LIMITS.maxResultBytes} bytes.`); - } - return result ?? null; -} - -/** - * Sends `console.log`, `info`, and `debug` to stderr. Stdout carries the - * protocol; a stray line there would corrupt the stream and end the session. - */ -function redirectConsole(errorOutput) { - const write = (...parts) => errorOutput.write(`${format(...parts)}\n`); - console.log = write; - console.info = write; - console.debug = write; -} diff --git a/sdk/node/src/errors.mjs b/sdk/node/src/errors.mjs deleted file mode 100644 index 7df9ace514..0000000000 --- a/sdk/node/src/errors.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import { boundedText, ERROR_CODES, isPluginErrorCode } from './protocol.mjs'; - -/** - * A failure a handler reports on purpose. Core ends the call with the stable - * `plugin.function.failed` code and forwards `code` as `pluginErrorCode`, so - * the plugin's own UI can tell its failures apart. Lithe never branches on it. - */ -export class PluginError extends Error { - /** - * @param {string} code Identifier of at most 64 bytes, such as `maven.snapshotLocked`. - * @param {string} message User-facing text; Core keeps the first 1024 bytes. - * @param {object} [options] `{ cause }`, kept for logs only. - */ - constructor(code, message, options) { - super(message, options); - if (!isPluginErrorCode(code)) { - throw new TypeError( - `Plugin error code ${JSON.stringify(code)} must start with a letter, use letters, digits, ".", "_" or "-", and be at most 64 bytes.`, - ); - } - this.name = 'PluginError'; - this.code = code; - } -} - -/** An SDK usage error, raised synchronously to the plugin author. */ -export class PluginSdkError extends Error { - constructor(code, message) { - super(message); - this.name = 'PluginSdkError'; - this.code = code; - } -} - -/** True for the rejection an aborted `AbortSignal` produces. */ -export function isAbort(error, signal) { - return Boolean(signal?.aborted) && (error === signal.reason || error?.name === 'AbortError'); -} - -/** - * The JSON-RPC error object for a handler failure. Only `PluginError` carries - * a plugin code; any other exception becomes a generic internal error so that - * stack traces and machine paths never reach the host's UI. A `PluginError` - * message is shown to the user as written, so it must not contain them either. - */ -export function toResponseError(error, signal) { - if (isAbort(error, signal)) { - return { code: ERROR_CODES.requestCancelled, message: 'The request was cancelled.' }; - } - if (error instanceof PluginError) { - return { - code: ERROR_CODES.requestFailed, - message: boundedText(error.message), - data: { code: error.code }, - }; - } - // Unexpected exceptions often embed absolute paths (ENOENT) or internals; - // the caller logs the details to stderr instead. - return { code: ERROR_CODES.internalError, message: 'The plugin backend failed unexpectedly.' }; -} diff --git a/sdk/node/src/framing.mjs b/sdk/node/src/framing.mjs deleted file mode 100644 index ccfe7742e1..0000000000 --- a/sdk/node/src/framing.mjs +++ /dev/null @@ -1,74 +0,0 @@ -// LSP base-protocol framing: `Content-Length` headers followed by a UTF-8 -// JSON body. Lithe talks to every plugin backend this way, so the SDK owns -// the framing and plugin code never writes to stdout itself. - -const HEADER_END = Buffer.from('\r\n\r\n', 'ascii'); - -/** Largest header block accepted from the host. */ -const MAX_HEADER_BYTES = 8192; - -/** Largest body accepted from the host. Larger frames end the session. */ -export const MAX_FRAME_BYTES = 64 * 1024 * 1024; - -/** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ -export const MALFORMED = Object.freeze({ malformed: true }); - -/** A framing error the session cannot recover from. */ -export class FramingError extends Error {} - -/** Incrementally splits a byte stream into decoded JSON-RPC messages. */ -export class FrameDecoder { - #buffer = Buffer.alloc(0); - - /** - * Appends `chunk` and returns every complete message it finished. A body - * that is not JSON is returned as `MALFORMED` so the caller can - * report it without losing the framing position. - */ - push(chunk) { - this.#buffer = this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]); - const messages = []; - for (;;) { - const headerEnd = this.#buffer.indexOf(HEADER_END); - // The same bound whether the header arrived whole or in pieces. - if (headerEnd > MAX_HEADER_BYTES || (headerEnd < 0 && this.#buffer.length > MAX_HEADER_BYTES)) { - throw new FramingError('The message header is too long.'); - } - if (headerEnd < 0) return messages; - const length = contentLength(this.#buffer.subarray(0, headerEnd).toString('ascii')); - const bodyStart = headerEnd + HEADER_END.length; - if (this.#buffer.length < bodyStart + length) { - return messages; - } - const body = this.#buffer.subarray(bodyStart, bodyStart + length).toString('utf8'); - this.#buffer = this.#buffer.subarray(bodyStart + length); - try { - messages.push(JSON.parse(body)); - } catch { - messages.push(MALFORMED); - } - } - } -} - -function contentLength(header) { - for (const line of header.split('\r\n')) { - const separator = line.indexOf(':'); - if (separator < 0) continue; - if (line.slice(0, separator).trim().toLowerCase() !== 'content-length') continue; - const value = line.slice(separator + 1).trim(); - if (!/^\d+$/.test(value)) break; - const length = Number(value); - if (length > MAX_FRAME_BYTES) { - throw new FramingError(`A message of ${length} bytes exceeds the ${MAX_FRAME_BYTES}-byte limit.`); - } - return length; - } - throw new FramingError('A message has no valid Content-Length header.'); -} - -/** Encodes one JSON-RPC message as a complete frame. */ -export function encodeFrame(message) { - const body = Buffer.from(JSON.stringify(message), 'utf8'); - return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]); -} diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts deleted file mode 100644 index 8841d2461a..0000000000 --- a/sdk/node/src/index.d.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Type declarations for the Lithe plugin backend SDK. Keep in sync with the -// `.mjs` sources; `sdk/node/test/support.test.mjs` checks the exported names. - -/** Any value JSON can carry. */ -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; -export type JsonObject = { [key: string]: JsonValue }; - -export interface RunProcessOptions { - cwd?: string; - /** Replaces the environment when given; Lithe's process markers are carried over regardless. */ - env?: Record; - /** Aborting it ends the whole process tree and rejects with its reason. */ - signal?: AbortSignal; - /** Captured bytes per stream; default 1 MiB. */ - maxOutputBytes?: number; - onStdout?: (chunk: string) => void; - onStderr?: (chunk: string) => void; - /** Delay between the polite and forced stop; default 2000. */ - killGraceMilliseconds?: number; -} - -export interface ProcessResult { - exitCode: number | null; - signal: string | null; - stdout: string; - stderr: string; - truncated: boolean; -} - -/** Shared by every handler. */ -export interface RequestContext { - /** Aborted when the host cancels the call, the call times out, the session ends, or the call has settled. */ - signal: AbortSignal; - throwIfCancelled(): void; - /** `runProcess` bound to this call: its tree ends when the call is cancelled or settles. */ - run(command: string, args?: string[], options?: RunProcessOptions): Promise; - /** Writes a line to stderr, which the host keeps in its log. */ - log(...parts: unknown[]): void; -} - -export interface ProgressReport { - /** Cut to 1024 bytes. */ - message?: string; - /** Rounded and clamped to 0-100. */ - percentage?: number; -} - -export interface FunctionContext extends RequestContext { - functionID: string; - /** Opaque per-call ID chosen by Lithe. */ - operationID: string; - /** Reports progress; ignored once the call has ended. */ - progress(report: ProgressReport): void; -} - -export type FunctionHandler = (params: JsonObject, context: FunctionContext) => unknown | Promise; -export type BuildHandler = (params: BuildRequestParams, context: RequestContext) => JsonObject | Promise; - -/** Params of `lithe/build/model` and `lithe/build/dependencies`. */ -export interface BuildRequestParams { - buildSystemID: string; - root: string; - options: JsonObject; - optionArguments: string[]; - environment: Record; - /** Dependencies only: the selected project, or null for the workspace. */ - project?: { name: string; path: string } | null; - /** True when the user or a sync-file change asked to reload; skip any snapshot. */ - refresh?: boolean; - /** The build tool the host resolved; absent when it could not, so look it up yourself. */ - tool?: { executable: string }; - [key: string]: unknown; -} - -export interface Negotiated { - functions: boolean; - build: boolean; -} - -export interface BackendOptions { - name?: string; - version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved. */ - capabilities?: JsonObject; -} - -export interface ListenOptions { - input?: NodeJS.ReadableStream; - output?: NodeJS.WritableStream; - errorOutput?: NodeJS.WritableStream; - /** Defaults to `process.exit`; null only resolves the returned promise. */ - exit?: ((code: number) => void) | null; -} - -export interface Backend { - readonly negotiated: Negotiated; - readonly initializeParams: JsonObject | null; - readonly functionIDs: string[]; - readonly build: { - model(handler: BuildHandler): Backend; - dependencies(handler: BuildHandler): Backend; - }; - function(id: string, handler: FunctionHandler): Backend; - registerFunction(id: string, handler: FunctionHandler): Promise; - unregisterFunction(id: string): Promise; - onRequest(method: string, handler: (params: unknown, context: RequestContext) => unknown): Backend; - onNotification(method: string, handler: (params: unknown) => unknown): Backend; - onInitialize(hook: (event: { params: JsonObject; negotiated: Negotiated }) => unknown): Backend; - onShutdown(hook: () => unknown): Backend; - sendRequest(method: string, params?: unknown): Promise; - sendNotification(method: string, params?: unknown): void; - log(...parts: unknown[]): void; - listen(options?: ListenOptions): Promise; -} - -export function createBackend(options?: BackendOptions): Backend; - -export class PluginError extends Error { - constructor(code: string, message: string, options?: { cause?: unknown }); - readonly code: string; -} - -export class PluginSdkError extends Error { - readonly code: string; -} - -export function runProcess(command: string, args?: string[], options?: RunProcessOptions): Promise; - -export interface StateStore { - readonly directory: string; - read(key: string, fallback?: T): T | undefined; - write(key: string, value: JsonValue): void; - delete(key: string): void; -} - -export function openStateStore(directory: string, options?: { packageDirectory?: string | null }): StateStore; - -export function isFunctionId(value: unknown): boolean; - -export const LIMITS: Readonly<{ - maxFunctions: number; - maxFunctionIdBytes: number; - maxPluginErrorCodeBytes: number; - maxMessageBytes: number; - maxResultBytes: number; -}>; diff --git a/sdk/node/src/index.mjs b/sdk/node/src/index.mjs deleted file mode 100644 index 6263ac8288..0000000000 --- a/sdk/node/src/index.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// Official Node.js SDK for Lithe plugin backends. -// Guide: sdk/node/README.md. Wire contract: shared/contracts/plugins/v1/README.md. - -export { createBackend } from './backend.mjs'; -export { PluginError, PluginSdkError } from './errors.mjs'; -export { runProcess } from './process.mjs'; -export { openStateStore } from './state.mjs'; -export { isFunctionId, LIMITS } from './protocol.mjs'; diff --git a/sdk/node/src/process.mjs b/sdk/node/src/process.mjs deleted file mode 100644 index 03f59df6bc..0000000000 --- a/sdk/node/src/process.mjs +++ /dev/null @@ -1,188 +0,0 @@ -// Child processes a backend starts for one call. Lithe's host offers no -// process capability in protocol version 1, so a backend runs its tools -// itself; this helper makes sure that the call ending, cancelling it, or the -// backend shutting down also ends the whole process tree it started. When the -// backend itself is killed no JavaScript runs; the host then ends what is left -// through its own process lifecycle, which owns that boundary. - -import { spawn } from 'node:child_process'; -import { StringDecoder } from 'node:string_decoder'; - -const isWindows = process.platform === 'win32'; - -/** Every process tree started through `runProcess` that has not exited. */ -const running = new Set(); - -/** - * Variables Lithe sets on the backend so it can find and end every process - * the backend started, even one that left its process group. A tool always - * inherits them, also when the plugin replaces the environment. - */ -const HOST_MARKERS = ['LITHE_PROCESS_SCOPE', 'LITHE_PLUGIN_BACKEND_SCOPE']; - -function withHostMarkers(env) { - const result = { ...env }; - for (const name of HOST_MARKERS) { - if (process.env[name] === undefined) delete result[name]; - else result[name] = process.env[name]; - } - return result; -} - -/** - * Runs `command` without a shell and resolves with its exit status and - * captured output. Aborting `signal` ends the process tree and rejects with - * the signal's reason. A non-zero exit resolves; the caller decides what it - * means for its tool. - * - * @param {string} command Executable path or name. - * @param {string[]} [args] - * @param {object} [options] - * @param {string} [options.cwd] - * @param {Record} [options.env] Replaces the environment when given; the - * host's process markers are carried over regardless. - * @param {AbortSignal} [options.signal] - * @param {number} [options.maxOutputBytes] Captured bytes per stream; default 1 MiB. - * @param {(chunk: string) => void} [options.onStdout] - * @param {(chunk: string) => void} [options.onStderr] - * @param {number} [options.killGraceMilliseconds] Delay between the polite and forced stop; default 2000. - */ -export function runProcess(command, args = [], options = {}) { - const { cwd, env, signal, onStdout, onStderr } = options; - const maxOutputBytes = options.maxOutputBytes ?? 1 << 20; - const killGraceMilliseconds = options.killGraceMilliseconds ?? 2000; - if (signal?.aborted) return Promise.reject(signal.reason); - - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env: env && withHostMarkers(env), - shell: false, - windowsHide: true, - // Its own process group on POSIX, so a stop reaches grandchildren too. - detached: !isWindows, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const entry = { child, stopping: false, forceTimer: null }; - running.add(entry); - const stdout = capture(maxOutputBytes, onStdout); - const stderr = capture(maxOutputBytes, onStderr); - child.stdout.on('data', stdout.push); - child.stderr.on('data', stderr.push); - - const onAbort = () => stopTree(entry, killGraceMilliseconds); - signal?.addEventListener('abort', onAbort, { once: true }); - - let settled = false; - const finish = (outcome) => { - if (settled) return; - settled = true; - // A stopped tree stays registered until its forced stop: the leader - // can exit on SIGTERM while a grandchild in its group ignores it. - if (!entry.stopping) { - // The leader ended on its own; members it left behind in its group, - // such as a background worker, end with it. - signalTree(child, 'SIGKILL', { evenAfterExit: true }); - running.delete(entry); - } - signal?.removeEventListener('abort', onAbort); - stdout.end(); - stderr.end(); - outcome(); - }; - child.once('error', (error) => finish(() => reject(error))); - child.once('close', (exitCode, exitSignal) => - finish(() => { - if (signal?.aborted) { - reject(signal.reason); - return; - } - resolve({ - exitCode, - signal: exitSignal, - stdout: stdout.text(), - stderr: stderr.text(), - truncated: stdout.truncated() || stderr.truncated(), - }); - }), - ); - }); -} - -/** - * Forcibly ends every process tree still running. The backend calls it on - * shutdown, on `exit`, and when the host closes stdin, so no tool outlives - * the session that started it. - */ -export function killAllProcesses() { - for (const entry of running) { - clearTimeout(entry.forceTimer); - signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); - } - running.clear(); -} - -/** Number of process trees still running or being stopped, for tests and diagnostics. */ -export function runningProcessCount() { - return running.size; -} - -function stopTree(entry, graceMilliseconds) { - if (entry.stopping) return; - entry.stopping = true; - signalTree(entry.child, 'SIGTERM'); - entry.forceTimer = setTimeout(() => { - running.delete(entry); - signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); - }, graceMilliseconds); - entry.forceTimer.unref?.(); -} - -/** - * Signals the child's whole tree. `evenAfterExit` still signals the POSIX - * process group after its leader exited, which reaches members that survived - * the polite stop. While any member lives the group ID cannot be reused; - * once all exited the signal fails with ESRCH, which is ignored. - */ -function signalTree(child, signalName, { evenAfterExit = false } = {}) { - if (child.pid === undefined) return; - const exited = child.exitCode !== null || child.signalCode !== null; - if (exited && (isWindows || !evenAfterExit)) return; - try { - if (isWindows) { - // taskkill /T is the only stock way to end a Windows process tree. - spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); - } else { - process.kill(-child.pid, signalName); - } - } catch { - // The group already exited between the check and the signal. - } -} - -function capture(limit, listener) { - const chunks = []; - // Keeps a multi-byte character split across chunks whole in `listener`. - const decoder = new StringDecoder('utf8'); - let size = 0; - let truncated = false; - const deliver = (text) => { - if (text) listener?.(text); - }; - return { - push(chunk) { - deliver(decoder.write(chunk)); - if (size >= limit) { - truncated = true; - return; - } - const kept = chunk.subarray(0, limit - size); - truncated ||= kept.length < chunk.length; - chunks.push(kept); - size += kept.length; - }, - end: () => deliver(decoder.end()), - text: () => Buffer.concat(chunks).toString('utf8'), - truncated: () => truncated, - }; -} diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs deleted file mode 100644 index cb13ce88b9..0000000000 --- a/sdk/node/src/protocol.mjs +++ /dev/null @@ -1,92 +0,0 @@ -// Wire constants and validation shared with Rust Core -// (rust/lithe-core/src/lsp/interface/plugin_function.rs). The SDK checks the -// same rules locally so a plugin author sees a mistake when registering a -// handler instead of as a refused session at runtime. - -/** Protocol versions this SDK can answer. */ -export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 1; - -export const METHODS = Object.freeze({ - initialize: 'initialize', - initialized: 'initialized', - shutdown: 'shutdown', - exit: 'exit', - cancelRequest: '$/cancelRequest', - functionInvoke: 'lithe/function/invoke', - functionRegister: 'lithe/function/register', - functionUnregister: 'lithe/function/unregister', - functionProgress: 'lithe/function/progress', - buildModel: 'lithe/build/model', - buildDependencies: 'lithe/build/dependencies', -}); - -/** JSON-RPC and LSP error codes the SDK answers with. */ -export const ERROR_CODES = Object.freeze({ - parseError: -32700, - invalidRequest: -32600, - methodNotFound: -32601, - invalidParams: -32602, - internalError: -32603, - serverNotInitialized: -32002, - requestFailed: -32000, - requestCancelled: -32800, -}); - -export const LIMITS = Object.freeze({ - maxFunctions: 256, - maxFunctionIdBytes: 128, - maxPluginErrorCodeBytes: 64, - maxMessageBytes: 1024, - maxResultBytes: 1 << 20, -}); - -/** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ -export function isFunctionId(value) { - return ( - typeof value === 'string' && - value.length > 0 && - Buffer.byteLength(value) <= LIMITS.maxFunctionIdBytes && - value.split('.').every((segment) => /^[a-z][A-Za-z0-9]*$/.test(segment)) - ); -} - -/** The plugin error code alphabet Core forwards as `pluginErrorCode`. */ -export function isPluginErrorCode(value) { - return ( - typeof value === 'string' && - Buffer.byteLength(value) <= LIMITS.maxPluginErrorCodeBytes && - /^[A-Za-z][A-Za-z0-9._-]*$/.test(value) - ); -} - -/** Cuts `value` to `limit` UTF-8 bytes without splitting a character. */ -export function boundedText(value, limit = LIMITS.maxMessageBytes) { - const text = String(value ?? ''); - if (Buffer.byteLength(text) <= limit) return text; - let used = 0; - let end = 0; - for (const character of text) { - const size = Buffer.byteLength(character); - if (used + size > limit) break; - used += size; - end += character.length; - } - return text.slice(0, end); -} - -/** - * Methods reserved for the SDK. A plugin cannot install its own handler for - * these, or send them to the host: the lifecycle belongs to the host's - * session management, and `lithe/*` belongs to Lithe's typed contracts. - */ -export function isReservedMethod(method) { - return ( - method === METHODS.initialize || - method === METHODS.initialized || - method === METHODS.shutdown || - method === METHODS.exit || - method === METHODS.cancelRequest || - method.startsWith('lithe/') - ); -} diff --git a/sdk/node/src/state.mjs b/sdk/node/src/state.mjs deleted file mode 100644 index f648bb3da6..0000000000 --- a/sdk/node/src/state.mjs +++ /dev/null @@ -1,148 +0,0 @@ -// Durable plugin state. The host gives every backend writable directories -// through the `${stateDir}` and `${workspaceStateDir}` launch variables; the -// installed package and the host's installation are read-only, because -// signatures and delta updates are computed over their exact bytes. - -import { - closeSync, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { randomBytes } from 'node:crypto'; -import { PluginSdkError } from './errors.mjs'; - -/** Version of the envelope each value is stored in. */ -const STORE_FORMAT = 1; -const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; - -/** - * Opens a key-value store in `directory`, creating the directory. Values are - * JSON; each key is one file replaced atomically, so a crash leaves either the - * old or the new value, never half of one. - * - * @param {string} directory Absolute path, normally the value of `${workspaceStateDir}`. - * @param {object} [options] - * @param {string | null} [options.packageDirectory] The installed plugin package, - * which the store refuses to write into. Defaults to the nearest directory - * containing `plugin.json` above the running entry script. - */ -export function openStateStore(directory, options = {}) { - if (typeof directory !== 'string' || !isAbsolute(directory)) { - throw new PluginSdkError('sdk.state.invalidDirectory', 'The state directory must be an absolute path passed by the host.'); - } - const packageDirectory = options.packageDirectory === undefined ? findPackageDirectory() : options.packageDirectory; - // Checked before creating anything, so a refused path leaves the package untouched. - const root = canonical(directory); - if (packageDirectory && isInside(root, canonical(packageDirectory))) { - throw new PluginSdkError( - 'sdk.state.readOnlyPackage', - 'The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}.', - ); - } - mkdirSync(root, { recursive: true }); - return new StateStore(root); -} - -class StateStore { - #root; - - constructor(root) { - this.#root = root; - } - - /** The resolved directory, for a backend that keeps its own files next to the store. */ - get directory() { - return this.#root; - } - - /** The stored value, or `fallback` when the key is missing or unreadable. */ - read(key, fallback = undefined) { - const path = this.#path(key); - if (!existsSync(path)) return fallback; - try { - const envelope = JSON.parse(readFileSync(path, 'utf8')); - if (envelope?.litheStateStore !== STORE_FORMAT || !('value' in envelope)) return fallback; - return envelope.value; - } catch { - return fallback; - } - } - - /** Stores `value`, which must be JSON-serializable, replacing the previous one. */ - write(key, value) { - const path = this.#path(key); - // `undefined`, a function, or a symbol would vanish from the envelope and - // silently replace the stored value with nothing; refuse it and keep the file. - if (JSON.stringify(value) === undefined) { - throw new PluginSdkError('sdk.state.invalidValue', `The value for ${key} is not JSON-serializable.`); - } - const body = JSON.stringify({ litheStateStore: STORE_FORMAT, value }); - const temporary = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; - try { - writeFileSync(temporary, body, { encoding: 'utf8', mode: 0o600 }); - const handle = openSync(temporary, 'r'); - try { - fsyncSync(handle); - } finally { - closeSync(handle); - } - renameSync(temporary, path); - } catch (error) { - rmSync(temporary, { force: true }); - throw error; - } - } - - /** Removes the key; a missing key is not an error. */ - delete(key) { - rmSync(this.#path(key), { force: true }); - } - - #path(key) { - if (typeof key !== 'string' || !KEY.test(key) || key.endsWith('.tmp')) { - throw new PluginSdkError( - 'sdk.state.invalidKey', - `State key ${JSON.stringify(key)} must be 1-128 letters, digits, ".", "_" or "-", start with a letter or digit, and not end with ".tmp".`, - ); - } - return join(this.#root, `${key}.json`); - } -} - -function isInside(path, parent) { - const difference = relative(parent, path); - return difference === '' || (!difference.startsWith(`..${sep}`) && difference !== '..' && !isAbsolute(difference)); -} - -/** Resolves symbolic links of the longest existing prefix of `path`. */ -function canonical(path) { - const missing = []; - let current = resolve(path); - while (!existsSync(current)) { - const parent = dirname(current); - if (parent === current) break; - missing.unshift(basename(current)); - current = parent; - } - return join(realpathSync(current), ...missing); -} - -function findPackageDirectory() { - const entry = process.argv[1]; - if (!entry) return null; - let current = dirname(resolve(entry)); - for (;;) { - if (existsSync(join(current, 'plugin.json'))) return current; - const parent = dirname(current); - if (parent === current) return null; - current = parent; - } -} diff --git a/sdk/node/test/backend.test.mjs b/sdk/node/test/backend.test.mjs deleted file mode 100644 index 937a680c33..0000000000 --- a/sdk/node/test/backend.test.mjs +++ /dev/null @@ -1,322 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; -import { createBackend, isFunctionId, PluginError, PluginSdkError } from '../src/index.mjs'; -import { gate, TestHost, withTimeout } from './harness.mjs'; - -const fixture = JSON.parse( - readFileSync(new URL('../../../shared/contracts/plugins/v1/fixtures/function-protocol.json', import.meta.url), 'utf8'), -); - -function functionBackend() { - return createBackend({ name: 'test-backend', version: '1.0.0' }) - .function('snapshot.refresh', (params) => ({ echoed: params })) - .function('install.prepare', () => undefined); -} - -test('initialize answers the protocols the host offered, with sorted functions', async () => { - const backend = functionBackend(); - backend.build.model(() => ({ projects: [] })); - const host = new TestHost(backend); - const { result } = await host.initialize(); - assert.deepEqual(result.capabilities.experimental, { - litheBuild: { version: 1 }, - litheFunctions: { version: 1, functions: [{ id: 'install.prepare' }, { id: 'snapshot.refresh' }] }, - }); - assert.deepEqual(result.serverInfo, { name: 'test-backend', version: '1.0.0' }); - assert.deepEqual(backend.negotiated, { functions: true, build: true }); - assert.equal(await host.close(), 1); -}); - -test('a host that offers nothing gets no Lithe capabilities, and a backend without build handlers never claims litheBuild', async () => { - const quiet = new TestHost(functionBackend()); - assert.deepEqual((await quiet.initialize({})).result.capabilities.experimental, {}); - await quiet.close(); - - const other = new TestHost(functionBackend()); - const answer = (await other.initialize({ litheBuild: { versions: [1] }, litheFunctions: { versions: [2] } })).result; - assert.deepEqual(answer.capabilities.experimental, {}); - await other.close(); -}); - -test('function IDs follow the shared contract, including the fixture cases', () => { - for (const id of ['snapshot.refresh', 'install.prepare', 'a', 'x1.y2Z']) assert.ok(isFunctionId(id), id); - for (const id of ['', 'Not-Valid', 'snapshot..refresh', '1abc', 'a_b', 'é', 'a'.repeat(129)]) { - assert.ok(!isFunctionId(id), id); - } - // Every ID the fixture accepts must also be accepted locally, and the - // fixture's malformed ID refused, so the SDK never declares what Core rejects. - for (const testCase of fixture.negotiation) { - const ids = testCase.capabilities.experimental?.litheFunctions?.functions?.map(({ id }) => id) ?? []; - if (testCase.name.includes('malformed')) { - assert.ok(ids.some((id) => !isFunctionId(id)), testCase.name); - } else if (testCase.expected.functions) { - assert.ok(ids.every(isFunctionId), testCase.name); - } - } - assert.throws(() => createBackend().function('Bad-ID', () => {}), (error) => error.code === 'sdk.function.invalidID'); - assert.throws( - () => createBackend().function('a', () => {}).function('a', () => {}), - (error) => error.code === 'sdk.function.duplicate', - ); -}); - -test('the lifecycle and the lithe namespace stay reserved for the SDK', async () => { - const backend = createBackend(); - for (const method of ['initialize', 'shutdown', 'exit', '$/cancelRequest', 'lithe/build/model', 'lithe/anything']) { - assert.throws(() => backend.onRequest(method, () => {}), PluginSdkError, method); - } - assert.throws( - () => createBackend({ capabilities: { experimental: { litheFunctions: { version: 1 } } } }), - (error) => error.code === 'sdk.capabilities.reserved', - ); - const host = new TestHost(backend); - await host.initialize(); - // A backend never asks the host to dispatch a domain operation or a function. - for (const method of ['lithe/function/invoke', 'lithe/build/model', 'lithe/host/process.run']) { - assert.throws(() => backend.sendRequest(method, {}), (error) => error.code === 'sdk.method.reserved', method); - } - await host.close(); -}); - -test('requests before initialize are refused with ServerNotInitialized', async () => { - const host = new TestHost(functionBackend()); - const response = await host.request('lithe/function/invoke', { functionID: 'snapshot.refresh', operationID: 'op', params: {} }); - assert.equal(response.error.code, -32002); - await host.close(); -}); - -test('an invocation returns its result; undefined becomes null', async () => { - const host = new TestHost(functionBackend()); - await host.initialize(); - const response = await host.request('lithe/function/invoke', { - functionID: 'snapshot.refresh', - operationID: 'op-1', - params: { project: 'app' }, - }); - assert.deepEqual(response.result, { echoed: { project: 'app' } }); - const empty = await host.request('lithe/function/invoke', { functionID: 'install.prepare', operationID: 'op-2' }); - assert.equal(empty.result, null); - const unknown = await host.request('lithe/function/invoke', { functionID: 'missing', operationID: 'op-3' }); - assert.equal(unknown.error.code, -32602); - await host.close(); -}); - -test('progress echoes the operation ID, is bounded, and stops once the call ended', async () => { - const release = gate(); - let late; - const backend = createBackend().function('long.task', async (_params, context) => { - context.progress({ message: 'x'.repeat(2000), percentage: 140.4 }); - context.progress({ message: 'half', percentage: 49.6 }); - late = context.progress; - await release.opened; - return 'done'; - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'long.task', operationID: 'wire-7' }); - const first = await host.waitFor((message) => message.method === 'lithe/function/progress'); - assert.equal(first.params.operationID, 'wire-7'); - assert.equal(first.params.message.length, 1024); - assert.equal(first.params.percentage, 100); - const second = await host.waitFor((message) => message.params?.message === 'half'); - assert.equal(second.params.percentage, 50); - release.open(); - assert.equal((await response).result, 'done'); - late({ message: 'too late' }); - await host.close(); - assert.ok(!host.messages.some((message) => message.params?.message === 'too late')); -}); - -test('a PluginError carries its code; other failures hide their details', async () => { - const backend = createBackend() - .function('locked', () => { - throw new PluginError('maven.snapshotLocked', 'Snapshot is locked.'); - }) - .function('crash', () => { - throw new Error('ENOENT: no such file, open /Users/someone/secret'); - }); - const host = new TestHost(backend); - await host.initialize(); - const locked = await host.request('lithe/function/invoke', { functionID: 'locked', operationID: 'a' }); - assert.deepEqual(locked.error, { code: -32000, message: 'Snapshot is locked.', data: { code: 'maven.snapshotLocked' } }); - const crash = await host.request('lithe/function/invoke', { functionID: 'crash', operationID: 'b' }); - assert.equal(crash.error.code, -32603); - assert.ok(!crash.error.message.includes('/Users/')); - assert.equal(crash.error.data, undefined); - assert.throws(() => new PluginError('bad code!', 'x'), TypeError); - await host.close(); -}); - -test('$/cancelRequest aborts the handler and answers RequestCancelled', async () => { - const started = gate(); - let observed; - const backend = createBackend().function('wait.forever', (_params, { signal }) => { - started.open(); - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - observed = true; - reject(signal.reason); - }); - }); - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'c' }, 41); - await withTimeout(started.opened, 'the handler to start'); - host.notify('$/cancelRequest', { id: 41 }); - assert.equal((await response).error.code, -32800); - assert.ok(observed); - await host.close(); -}); - -test('registerFunction and unregisterFunction go through the host, all or nothing', async () => { - const backend = createBackend(); - const host = new TestHost(backend); - await host.initialize(); - - const registering = backend.registerFunction('late.one', () => 'late'); - const register = await host.waitFor((message) => message.method === 'lithe/function/register'); - assert.deepEqual(register.params, { functions: [{ id: 'late.one' }] }); - host.send({ id: register.id, result: null }); - await registering; - const call = await host.request('lithe/function/invoke', { functionID: 'late.one', operationID: 'd' }); - assert.equal(call.result, 'late'); - - // A refused registration leaves no handler behind. - const refused = backend.registerFunction('other.one', () => 0); - const second = await host.waitFor((message) => message.params?.functions?.[0]?.id === 'other.one'); - host.send({ id: second.id, error: { code: -32602, message: 'No.', data: { code: 'plugin.function.invalidRegistration' } } }); - await assert.rejects(refused, (error) => error.code === 'plugin.function.invalidRegistration'); - assert.deepEqual(backend.functionIDs, ['late.one']); - - const unregistering = backend.unregisterFunction('late.one'); - const unregister = await host.waitFor((message) => message.method === 'lithe/function/unregister'); - assert.deepEqual(unregister.params, { ids: ['late.one'] }); - host.send({ id: unregister.id, result: null }); - await unregistering; - assert.deepEqual(backend.functionIDs, []); - await host.close(); -}); - -test('build handlers get the canonical format stamped, and a wrong format fails', async () => { - const backend = createBackend(); - backend.build.model(({ root }) => ({ projects: [{ name: 'app', path: root }] })); - backend.build.dependencies(() => ({ format: 'litheBuildModel', projects: [] })); - const host = new TestHost(backend); - await host.initialize(); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.deepEqual(model.result, { format: 'litheBuildModel', version: 1, projects: [{ name: 'app', path: '.' }] }); - const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); - assert.equal(dependencies.error.code, -32603); - await host.close(); -}); - -test('a build result cannot replace the version the SDK stamps', async () => { - const backend = createBackend(); - backend.build.model(() => ({ version: 2, projects: [] })); - backend.build.dependencies(() => ({ version: 1, dependencies: [] })); - const host = new TestHost(backend); - await host.initialize(); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error.code, -32603); - const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); - assert.deepEqual(dependencies.result, { format: 'litheDependencies', version: 1, dependencies: [] }); - await host.close(); -}); - -test('a result JSON cannot encode, or one over 1 MiB, fails the call and the session continues', async () => { - const cycle = {}; - cycle.self = cycle; - const backend = createBackend() - .function('result.bigint', () => 1n) - .function('result.cycle', () => cycle) - .function('result.symbol', () => Symbol('value')) - .function('result.large', () => 'x'.repeat(1 << 20)) - .function('result.fine', () => ({ ok: true })); - backend.build.model(() => ({ projects: [{ name: 'app', path: '.', size: 1n }] })); - const host = new TestHost(backend); - await host.initialize(); - let operation = 0; - for (const functionID of ['result.bigint', 'result.cycle', 'result.symbol', 'result.large']) { - const response = await host.request('lithe/function/invoke', { functionID, operationID: `op-${operation++}` }); - assert.equal(response.error?.code, -32603, functionID); - } - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error?.code, -32603); - const fine = await host.request('lithe/function/invoke', { functionID: 'result.fine', operationID: 'op-last' }); - assert.deepEqual(fine.result, { ok: true }); - assert.equal(await host.close(), 1); -}); - -test('build requests are refused when the host did not negotiate litheBuild', async () => { - const backend = createBackend(); - backend.build.model(() => ({ projects: [] })); - const host = new TestHost(backend); - await host.initialize({ litheFunctions: { versions: [1] } }); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error.code, -32601); - await host.close(); -}); - -test('shutdown cancels running calls and runs hooks; exit after shutdown is code 0', async () => { - const started = gate(); - const order = []; - const backend = createBackend() - .function('wait.forever', (_params, { signal }) => { - started.open(); - return new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason))); - }) - .onShutdown(() => order.push('hook')); - const host = new TestHost(backend); - await host.initialize(); - const call = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'e' }); - await withTimeout(started.opened, 'the handler to start'); - const shutdown = await host.request('shutdown'); - assert.equal(shutdown.result, null); - assert.equal((await call).error.code, -32800); - assert.deepEqual(order, ['hook']); - const after = await host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'f' }); - assert.equal(after.error.code, -32600); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'exit'), 0); -}); - -test('exit without shutdown, or a closed input, ends with code 1', async () => { - const host = new TestHost(createBackend()); - await host.initialize(); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'exit'), 1); - assert.equal(await new TestHost(createBackend()).close(), 1); -}); - -test('a malformed body is answered with a parse error and the session continues', async () => { - const host = new TestHost(functionBackend()); - const body = Buffer.from('{not json'); - host.input.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body])); - const parseError = await host.waitFor((message) => message.error?.code === -32700); - assert.equal(parseError.id, null); - assert.ok((await host.initialize()).result); - await host.close(); -}); - -test('registering from an initialize hook joins the initial list without a request', async () => { - const backend = createBackend().function('a.one', () => 1); - backend.onInitialize(() => { - void backend.registerFunction('b.two', () => 2); - void backend.unregisterFunction('a.one'); - }); - const host = new TestHost(backend); - const { result } = await host.initialize(); - assert.deepEqual(result.capabilities.experimental.litheFunctions.functions, [{ id: 'b.two' }]); - assert.ok(!host.messages.some((message) => message.method?.startsWith('lithe/function/'))); - await host.close(); -}); - -test('runtime registration needs the negotiated protocol', async () => { - const backend = createBackend(); - const host = new TestHost(backend); - await host.initialize({}); - await assert.rejects(backend.registerFunction('late.one', () => 1), (error) => error.code === 'sdk.functions.unavailable'); - await host.close(); -}); diff --git a/sdk/node/test/harness.mjs b/sdk/node/test/harness.mjs deleted file mode 100644 index 4988ed5d7a..0000000000 --- a/sdk/node/test/harness.mjs +++ /dev/null @@ -1,106 +0,0 @@ -// A host for SDK tests: it writes framed messages to a backend, in memory or -// in a child process, and decodes the answers with a bounded wait on each. - -import { ChildProcess } from 'node:child_process'; -import { PassThrough } from 'node:stream'; -import { encodeFrame, FrameDecoder } from '../src/framing.mjs'; - -/** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ -export const WAIT_MILLISECONDS = 5000; - -export class TestHost { - messages = []; - #waiters = []; - #nextID = 1; - exitCode; - - /** Hosts `backend` in memory, or talks to a backend child process over its stdio as Lithe does. */ - constructor(backend) { - if (backend instanceof ChildProcess) { - this.input = backend.stdin; - this.output = backend.stdout; - this.errorOutput = backend.stderr; - this.done = new Promise((resolve) => backend.once('exit', (code) => resolve(code))); - } else { - this.input = new PassThrough(); - this.output = new PassThrough(); - this.errorOutput = new PassThrough(); - this.done = backend.listen({ input: this.input, output: this.output, errorOutput: this.errorOutput, exit: null }); - } - const decoder = new FrameDecoder(); - this.output.on('data', (chunk) => { - for (const message of decoder.push(chunk)) { - this.messages.push(message); - this.#waiters = this.#waiters.filter((waiter) => !waiter(message)); - } - }); - this.errorOutput.resume(); - } - - send(message) { - this.input.write(encodeFrame({ jsonrpc: '2.0', ...message })); - } - - /** Sends a request and resolves with the backend's response. */ - request(method, params, id = this.#nextID++) { - this.send({ id, method, params }); - return this.waitFor((message) => message.id === id && message.method === undefined, `response to ${method}`); - } - - notify(method, params) { - this.send({ method, params }); - } - - async initialize( - experimental = { litheBuild: { versions: [1] }, litheFunctions: { versions: [1] } }, - rootUri = 'file:///workspace', - ) { - const response = await this.request('initialize', { - processId: null, - rootUri, - capabilities: { experimental }, - }); - this.notify('initialized', {}); - return response; - } - - /** Resolves with the first message, past or future, that `matches`. */ - waitFor(matches, description = 'message') { - const existing = this.messages.find(matches); - if (existing) return Promise.resolve(existing); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); - this.#waiters.push((message) => { - if (!matches(message)) return false; - clearTimeout(timer); - resolve(message); - return true; - }); - }); - } - - /** Ends the session and resolves with the backend's exit code. */ - async close() { - this.input.end(); - return withTimeout(this.done, 'the backend to exit'); - } -} - -export function withTimeout(promise, description) { - let timer; - return Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); - }), - ]).finally(() => clearTimeout(timer)); -} - -/** A promise with its resolver, for handlers a test releases explicitly. */ -export function gate() { - let open; - const opened = new Promise((resolve) => { - open = resolve; - }); - return { open, opened }; -} diff --git a/sdk/node/test/sample-build.test.mjs b/sdk/node/test/sample-build.test.mjs deleted file mode 100644 index cc66be2c29..0000000000 --- a/sdk/node/test/sample-build.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { TestHost } from './harness.mjs'; - -const sampleBackend = fileURLToPath(new URL('../examples/sample-build/backend/main.mjs', import.meta.url)); - -/** Starts the sample backend on a workspace with two quick projects, as Lithe would. */ -async function startSample(t) { - const root = mkdtempSync(join(tmpdir(), 'lithe-sample-build-')); - const workspace = join(root, 'workspace'); - const state = join(root, 'state'); - const quick = ['node', '-e', 'process.exit(0)']; - for (const name of ['app', 'lib']) mkdirSync(join(workspace, name), { recursive: true }); - mkdirSync(state); - writeFileSync( - join(workspace, 'lithe-sample.json'), - JSON.stringify({ projects: [{ name: 'app', path: 'app', build: quick }, { name: 'lib', path: 'lib', build: quick }] }), - ); - const child = spawn(process.execPath, [sampleBackend, '--state', state], { stdio: 'pipe' }); - t.after(() => { - child.kill(); - rmSync(root, { recursive: true, force: true }); - }); - const host = new TestHost(child); - await host.initialize(undefined, pathToFileURL(workspace).href); - return host; -} - -function build(host, operationID, params) { - return host.request('lithe/function/invoke', { functionID: 'workspace.build', operationID, params }); -} - -test('overlapping workspace builds record consecutive snapshot generations', async (t) => { - const host = await startSample(t); - // Both calls are in flight before either tool finishes. - const [first, second] = await Promise.all([build(host, 'op-1', {}), build(host, 'op-2', {})]); - assert.deepEqual([first.result.generation, second.result.generation].sort(), [1, 2]); - const latest = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-3' }); - assert.equal(latest.result.generation, 2); - await host.close(); -}); - -test('a selection naming an unknown project fails instead of building nothing', async (t) => { - const host = await startSample(t); - for (const [operationID, projects, code] of [ - ['op-1', ['typo'], 'sample.unknownProject'], - ['op-2', ['app', 'typo'], 'sample.unknownProject'], - ['op-3', [], 'sample.selection'], - ['op-4', 'app', 'sample.selection'], - ]) { - const response = await build(host, operationID, { projects }); - assert.equal(response.error?.data?.code, code, JSON.stringify(projects)); - } - const snapshot = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-5' }); - assert.equal(snapshot.result, null); - const selected = await build(host, 'op-6', { projects: ['lib'] }); - assert.deepEqual(selected.result.projects, [{ name: 'lib', exitCode: 0 }]); - await host.close(); -}); diff --git a/sdk/node/test/support.test.mjs b/sdk/node/test/support.test.mjs deleted file mode 100644 index efc52820f2..0000000000 --- a/sdk/node/test/support.test.mjs +++ /dev/null @@ -1,321 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { encodeFrame, FrameDecoder, FramingError } from '../src/framing.mjs'; -import { createBackend, openStateStore, runProcess } from '../src/index.mjs'; -import { runningProcessCount } from '../src/process.mjs'; -import { gate, TestHost, WAIT_MILLISECONDS, withTimeout } from './harness.mjs'; - -function temporaryDirectory(t) { - const directory = mkdtempSync(join(tmpdir(), 'lithe-sdk-')); - t.after(() => rmSync(directory, { recursive: true, force: true })); - return directory; -} - -/** Bounded poll of an OS-level fact the test cannot be notified about. */ -async function eventually(check, description) { - const deadline = Date.now() + WAIT_MILLISECONDS; - while (!check()) { - if (Date.now() > deadline) throw new Error(`Timed out waiting for ${description}.`); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -function isAlive(pid) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -test('frames split across chunks, or several in one chunk, decode in order', () => { - const decoder = new FrameDecoder(); - const bytes = Buffer.concat([encodeFrame({ id: 1, text: 'é✓' }), encodeFrame({ id: 2 })]); - const messages = []; - for (let index = 0; index < bytes.length; index += 3) { - messages.push(...decoder.push(bytes.subarray(index, index + 3))); - } - assert.deepEqual(messages, [{ id: 1, text: 'é✓' }, { id: 2 }]); - assert.throws(() => new FrameDecoder().push(Buffer.from('Content-Length: 999999999999\r\n\r\n')), FramingError); - assert.throws(() => new FrameDecoder().push(Buffer.from('X-Other: 1\r\n\r\n')), FramingError); -}); - -test('runProcess captures output and resolves a non-zero exit', async () => { - const result = await runProcess(process.execPath, ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)']); - assert.deepEqual( - { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, truncated: result.truncated }, - { exitCode: 3, stdout: 'out', stderr: 'err', truncated: false }, - ); - const limited = await runProcess(process.execPath, ['-e', 'process.stdout.write("x".repeat(100))'], { maxOutputBytes: 10 }); - assert.equal(limited.stdout, 'xxxxxxxxxx'); - assert.ok(limited.truncated); -}); - -test('cancelling a call ends the process tree it started', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { - const directory = temporaryDirectory(t); - const grandchildPid = join(directory, 'grandchild.pid'); - // The child starts a grandchild that ignores SIGTERM, so only the group's - // forced stop can end it. - const grandchild = `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(grandchildPid)}, String(process.pid)); setInterval(() => {}, 1000);`; - const child = `require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(grandchild)}], { stdio: "ignore" }); setInterval(() => {}, 1000);`; - const started = gate(); - const backend = createBackend().function('tool.run', async (_params, context) => { - const running = context.run(process.execPath, ['-e', child], { killGraceMilliseconds: 50 }); - started.open(); - return running; - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'tool.run', operationID: 'g' }, 7); - await withTimeout(started.opened, 'the tool to start'); - await eventually(() => existsSync(grandchildPid) && readFileSync(grandchildPid, 'utf8').length > 0, 'the grandchild'); - const pid = Number(readFileSync(grandchildPid, 'utf8')); - host.notify('$/cancelRequest', { id: 7 }); - assert.equal((await response).error.code, -32800); - await eventually(() => !isAlive(pid), 'the grandchild to be stopped'); - await eventually(() => runningProcessCount() === 0, 'the registry to drain'); - await host.close(); -}); - -test('closing the session ends tools still running', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { - const directory = temporaryDirectory(t); - const childPid = join(directory, 'child.pid'); - const started = gate(); - const backend = createBackend().function('tool.run', (_params, context) => { - const running = context.run(process.execPath, [ - '-e', - `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(childPid)}, String(process.pid)); setInterval(() => {}, 1000);`, - ]); - started.open(); - return running; - }); - const host = new TestHost(backend); - await host.initialize(); - // No reply is awaited: the session ends while the call is still running. - host.send({ id: 8, method: 'lithe/function/invoke', params: { functionID: 'tool.run', operationID: 'h' } }); - await withTimeout(started.opened, 'the tool to start'); - await eventually(() => existsSync(childPid) && readFileSync(childPid, 'utf8').length > 0, 'the child'); - const pid = Number(readFileSync(childPid, 'utf8')); - assert.equal(await host.close(), 1); - await eventually(() => !isAlive(pid), 'the child to be killed'); -}); - -test('the state store writes atomically and survives reopening', (t) => { - const directory = join(temporaryDirectory(t), 'workspace-state'); - const store = openStateStore(directory, { packageDirectory: null }); - assert.equal(store.read('snapshot', 'none'), 'none'); - store.write('snapshot', { generation: 1 }); - store.write('snapshot', { generation: 2 }); - assert.deepEqual(openStateStore(directory, { packageDirectory: null }).read('snapshot'), { generation: 2 }); - assert.deepEqual(readdirSync(directory), ['snapshot.json']); - writeFileSync(join(directory, 'broken.json'), '{'); - assert.equal(store.read('broken', null), null); - store.delete('snapshot'); - store.delete('snapshot'); - assert.equal(store.read('snapshot'), undefined); - for (const key of ['', '../escape', 'a/b', 'x.tmp', '.hidden']) { - assert.throws(() => store.write(key, 1), (error) => error.code === 'sdk.state.invalidKey', key); - } -}); - -test('the state store refuses the read-only plugin package', (t) => { - const root = temporaryDirectory(t); - const packageDirectory = join(root, 'package'); - mkdirSync(packageDirectory); - writeFileSync(join(packageDirectory, 'plugin.json'), '{}'); - assert.throws( - () => openStateStore(join(packageDirectory, 'state'), { packageDirectory }), - (error) => error.code === 'sdk.state.readOnlyPackage', - ); - // Refused before anything was created inside the package. - assert.ok(!existsSync(join(packageDirectory, 'state'))); - assert.throws(() => openStateStore('relative/state'), (error) => error.code === 'sdk.state.invalidDirectory'); - assert.ok(openStateStore(join(root, 'state'), { packageDirectory })); -}); - -test('the type declarations name exactly the runtime exports', async () => { - const runtime = Object.keys(await import('../src/index.mjs')).sort(); - const declarations = readFileSync(new URL('../src/index.d.ts', import.meta.url), 'utf8'); - const declared = [...declarations.matchAll(/^export (?:function|class|const) (\w+)/gm)].map((match) => match[1]); - assert.deepEqual([...new Set(declared)].sort(), runtime); -}); - -const posixOnly = { skip: process.platform === 'win32' && 'POSIX process groups' }; - -/** A Node one-liner that records its PID in `file` and runs until stopped. */ -function recordsPidAndWaits(file) { - return `require("fs").writeFileSync(${JSON.stringify(file)}, String(process.pid)); setInterval(() => {}, 1000);`; -} - -/** Every PID a test tool recorded, so cleanup still finds it after the temporary directory is gone. */ -const recordedPids = new Set(); - -function recordedPid(file) { - if (!existsSync(file) || readFileSync(file, 'utf8').length === 0) return undefined; - const pid = Number(readFileSync(file, 'utf8')); - recordedPids.add(pid); - return pid; -} - -/** Kills recorded tools still alive, so a failed assertion leaves nothing behind. */ -function killRecordedOnExit(t) { - t.after(() => { - for (const pid of recordedPids) { - if (isAlive(pid)) process.kill(pid, 'SIGKILL'); - } - }); -} - -test('a call that settles stops the tools its handler left running, and only those', posixOnly, async (t) => { - const directory = temporaryDirectory(t); - const failedSibling = join(directory, 'failed-sibling.pid'); - const floating = join(directory, 'floating.pid'); - const other = join(directory, 'other.pid'); - killRecordedOnExit(t); - const startTool = (context, file) => - context.run(process.execPath, ['-e', recordsPidAndWaits(file)], { killGraceMilliseconds: 50 }); - const backend = createBackend() - .function('parallel.fail', async (_params, context) => { - const slow = startTool(context, failedSibling); - await eventually(() => recordedPid(failedSibling) !== undefined, 'the sibling tool'); - await Promise.all([slow, context.run(join(directory, 'missing-tool'))]); - }) - .function('floating.succeed', async (_params, context) => { - startTool(context, floating); - await eventually(() => recordedPid(floating) !== undefined, 'the floating tool'); - return 'done'; - }) - .function('other.run', (_params, context) => startTool(context, other)); - const host = new TestHost(backend); - await host.initialize(); - const otherCall = host.request('lithe/function/invoke', { functionID: 'other.run', operationID: 'o' }, 40); - await eventually(() => recordedPid(other) !== undefined, 'the other call’s tool'); - - const failed = await host.request('lithe/function/invoke', { functionID: 'parallel.fail', operationID: 'f' }); - assert.equal(failed.error?.code, -32603); - await eventually(() => !isAlive(recordedPid(failedSibling)), 'the failed call’s sibling tool to stop'); - const succeeded = await host.request('lithe/function/invoke', { functionID: 'floating.succeed', operationID: 's' }); - assert.equal(succeeded.result, 'done'); - await eventually(() => !isAlive(recordedPid(floating)), 'the unawaited tool to stop'); - - // Another call's tool was not touched; it ends with its own call. - assert.ok(isAlive(recordedPid(other))); - host.notify('$/cancelRequest', { id: 40 }); - assert.equal((await otherCall).error.code, -32800); - await eventually(() => runningProcessCount() === 0, 'the registry to drain'); - assert.equal(await host.close(), 1); -}); - -test('a tool that exits normally also ends the workers it left in its group', posixOnly, async (t) => { - const directory = temporaryDirectory(t); - const worker = join(directory, 'worker.pid'); - killRecordedOnExit(t); - // The leader starts a background worker in its own group, waits until it - // runs, and exits 0 without stopping it. - const leader = ` - const { existsSync, readFileSync } = require("fs"); - const child = require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(recordsPidAndWaits(worker))}], { stdio: "ignore" }); - child.unref(); - const deadline = Date.now() + 5000; - const wait = () => existsSync(${JSON.stringify(worker)}) && readFileSync(${JSON.stringify(worker)}, "utf8") ? process.exit(0) - : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); - wait();`; - const result = await runProcess(process.execPath, ['-e', leader]); - assert.equal(result.exitCode, 0); - assert.equal(runningProcessCount(), 0); - await eventually(() => !isAlive(recordedPid(worker)), 'the worker to stop'); -}); - - -test('streamed output keeps a character split across chunks whole', async (t) => { - const directory = temporaryDirectory(t); - const released = join(directory, 'released'); - // The child writes "a" and the first byte of "中", waits until the parent - // saw that chunk, then writes the other two bytes, so "中" spans two chunks. - const child = ` - const { existsSync } = require("fs"); - process.stdout.write(Buffer.from([0x61, 0xe4])); - const deadline = Date.now() + 5000; - const wait = () => existsSync(${JSON.stringify(released)}) ? process.stdout.write(Buffer.from([0xb8, 0xad])) - : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); - wait();`; - const streamed = []; - const result = await runProcess(process.execPath, ['-e', child], { - onStdout: (text) => { - streamed.push(text); - writeFileSync(released, ''); - }, - }); - assert.equal(result.exitCode, 0); - assert.equal(result.stdout, 'a中'); - assert.deepEqual(streamed, ['a', '中']); -}); - -test('the state store refuses a value JSON would drop and keeps the stored one', (t) => { - const store = openStateStore(join(temporaryDirectory(t), 'state'), { packageDirectory: null }); - store.write('snapshot', { generation: 8 }); - for (const value of [undefined, () => 1, Symbol('value'), 1n]) { - assert.throws(() => store.write('snapshot', value), (error) => error.code === 'sdk.state.invalidValue' || error instanceof TypeError); - } - assert.deepEqual(store.read('snapshot'), { generation: 8 }); -}); - -test('a header over 8 KiB is refused also when the whole frame arrives at once', () => { - const header = `Content-Length: 2\r\nX-Padding: ${'x'.repeat(9000)}\r\n\r\n{}`; - assert.throws(() => new FrameDecoder().push(Buffer.from(header)), FramingError); -}); - -test('a JSON value that is not a message object is refused and the session continues', async () => { - const host = new TestHost(createBackend().function('ping', () => 'pong')); - await host.initialize(); - host.input.write(encodeFrame(null)); - const refused = await host.waitFor((message) => message.id === null && message.error, 'the refusal'); - assert.equal(refused.error.code, -32600); - const answered = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'p' }); - assert.equal(answered.result, 'pong'); - await host.close(); -}); - -test('a shutdown during a slow initialize hook keeps the session closed', async () => { - const hook = gate(); - const backend = createBackend() - .function('ping', () => 'pong') - .onInitialize(() => hook.opened); - const host = new TestHost(backend); - const initializing = host.request('initialize', { - processId: null, - rootUri: 'file:///workspace', - capabilities: { experimental: { litheFunctions: { versions: [1] } } }, - }); - const early = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'early' }); - assert.equal(early.error.code, -32002); - assert.equal((await host.request('shutdown')).result, null); - hook.open(); - assert.equal((await initializing).error?.code, -32600); - const late = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'late' }); - assert.equal(late.error.code, -32600); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'the backend to exit'), 0); -}); - -test('a replaced environment still carries the host process markers', async (t) => { - const saved = { scope: process.env.LITHE_PROCESS_SCOPE, backend: process.env.LITHE_PLUGIN_BACKEND_SCOPE }; - t.after(() => { - for (const [name, value] of [['LITHE_PROCESS_SCOPE', saved.scope], ['LITHE_PLUGIN_BACKEND_SCOPE', saved.backend]]) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } - }); - process.env.LITHE_PROCESS_SCOPE = 'instance-1'; - process.env.LITHE_PLUGIN_BACKEND_SCOPE = 'backend-1'; - const print = 'process.stdout.write(JSON.stringify([process.env.LITHE_PROCESS_SCOPE, process.env.LITHE_PLUGIN_BACKEND_SCOPE, process.env.PLUGIN_VALUE]))'; - // A plugin can neither drop nor forge the markers. - const result = await runProcess(process.execPath, ['-e', print], { - env: { PLUGIN_VALUE: 'kept', LITHE_PLUGIN_BACKEND_SCOPE: 'forged' }, - }); - assert.deepEqual(JSON.parse(result.stdout), ['instance-1', 'backend-1', 'kept']); -}); diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 3b66a6332a..981f4c0e98 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -11,7 +11,7 @@ This directory is the cross-platform source of truth: | File | Purpose | | --- | --- | | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | -| `fixtures/*.plugin.json` | Valid example manifests: language plugins (PHP, Go, Java, a discovered Node runtime), UI (`hello-lithe`), and level 3 build systems (Cargo, Go, Gradle, Maven, and two build providers) | +| `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | @@ -23,9 +23,18 @@ This directory is the cross-platform source of truth: Validation and template expansion are implemented once in Rust Core (`rust/lithe-core/src/plugins/`, with build systems in `rust/lithe-core/src/build/`) and exposed through the -`plugins.declarative.*` commands in `../../rust-core-api.md`. Hosts must not +`plugins.declarative.*` commands in [`shared/contracts/rust-core-api.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md). Hosts must not reimplement them. +Plugin authors do not need this repository. The public +[`Lithe-IDEA/lithe-plugin-docs`](https://github.com/Lithe-IDEA/lithe-plugin-docs) +repository holds the developer guide and a byte-identical copy of this whole +directory under `contract/v1/`, pinned to a revision of this repository; each +schema's `$id` is its URL there. Contract changes land here first and must then +be synced to that repository, so links in this file that leave this directory +are absolute. `plugin.json` may not carry a `$schema` key, because unknown +fields are rejected; editors associate the schema by file name instead. + ## Package layout ```text @@ -716,8 +725,12 @@ commands: Core validates the declaration, detects instances, decodes the model, and expands every command line; the host only runs the processes Core planned and passes their output back. A community plugin therefore needs no host change to add a build tool, and no host code branches on a build-system -ID. The fixtures `cargo-build`, `go-build`, `gradle-build`, and `maven-build` -are complete examples, `gradle-provider` and `mix-provider` show the two +ID. One complete fixture covers each model source: `build-static-file` reads +a project file, `build-command-stdout` decodes a command's standard output, +`build-command-output-file` decodes a file the command writes, and +`build-host-integration` hands the model to a host integration. They use +Cargo, Go, Gradle, and Maven as realistic inputs; they are not those +plugins. `build-provider` and `build-provider-language-server` show the two forms of a build provider, and `fixtures/build-plans.json` is the golden list of plans and decoded outputs every host gets from them. @@ -1126,25 +1139,30 @@ running on that session, is dropped. ### Official backend SDK -[`sdk/node`](../../../../sdk/node/README.md) is the official SDK for backends +The official SDKs live in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs), which +also releases them; they implement this contract and never define protocol. + +[`sdk/node`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/README.md) is the official SDK for backends started through a `node` runtime launch. It implements this protocol and -`litheBuild` 1, so a plugin author writes only handlers. It enforces the -rules above on the backend side: `lithe/*` methods are never sent, stdout +`litheBuild` 1, so a plugin author writes only handlers. It is released +(`sdk-node-v`) as a `.tgz` with its SHA-256 for plugins to vendor +into their package. It enforces the rules above on the backend side: `lithe/*` methods are never sent, stdout carries only protocol frames, `PluginError` is the only way to set a `pluginErrorCode`, processes started for a call end with it, and its state -store refuses the plugin package. Its sample plugin -(`sdk/node/examples/sample-build`) is driven through a real Core session by -`rust/lithe-core/tests/plugin_sdk_node.rs`, which the Rust Core lane runs with -`LITHE_PLUGIN_SDK_NODE` set. +store refuses the plugin package. -[`sdk/java`](../../../../sdk/java/README.md) is the Java twin for backends +[`sdk/java`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/java/README.md) is the Java twin for backends started through a `java` runtime launch. It needs Java 8 or newer, has no runtime dependencies, and is released as a JAR with its SHA-256 for plugins to shade into their backend. It enforces the same rules with the same error mapping, with one difference: on Java 8 it stops only a cancelled tool's -direct child, and the host's process scope ends the rest. Its sample plugin -(`sdk/java/examples/sample-build`) runs the same Core scenario through -`rust/lithe-core/tests/plugin_sdk_java.rs`. +direct child, and the host's process scope ends the rest. + +Each SDK has a sample plugin (`sdk/*/examples/sample-build`), and both samples +answer one end-to-end scenario driven through a real Core session. That +repository runs it against the Lithe commit its contract copy pins, and weekly +against the latest `preview`, the branch Lithe changes merge into. Neither SDK adds a wire field: a backend written without one is equally valid. @@ -1376,8 +1394,8 @@ existing Debug UI. Each must verify disable/re-enable, resource ownership and restoration; view-bearing samples also verify layout persistence. These samples are future acceptance criteria, not claims about current runtime support. -The design source is the [proposed public Extension Contract and Provider API](../../../../.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md). -Current runtime behavior is recorded in the [first-stage workbench UI Note](../../../../.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md). +The design source is the [proposed public Extension Contract and Provider API](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md). +Current runtime behavior is recorded in the [first-stage workbench UI Note](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md). ### Current Windows availability Windows can validate API level 2 through Rust Core but its UI adapter has not diff --git a/shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json similarity index 98% rename from shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json index 508ed70ef2..7d5c974c0b 100644 --- a/shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-build", - "displayName": "Gradle Build Fixture", + "id": "dev.lithe.fixture.build-command-output-file", + "displayName": "Command Output File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/go-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/go-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json index 84a6587ab5..8ed8855eca 100644 --- a/shared/contracts/plugins/v1/fixtures/go-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.go-build", - "displayName": "Go Build Fixture", + "id": "dev.lithe.fixture.build-command-stdout", + "displayName": "Command Stdout Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/maven-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json similarity index 98% rename from shared/contracts/plugins/v1/fixtures/maven-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json index 683075e176..01c58909f7 100644 --- a/shared/contracts/plugins/v1/fixtures/maven-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.maven-build", - "displayName": "Maven Build Fixture", + "id": "dev.lithe.fixture.build-host-integration", + "displayName": "Host Integration Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/build-plans.json b/shared/contracts/plugins/v1/fixtures/build-plans.json index 48f09e0122..4c7635778a 100644 --- a/shared/contracts/plugins/v1/fixtures/build-plans.json +++ b/shared/contracts/plugins/v1/fixtures/build-plans.json @@ -4,7 +4,7 @@ "cases": [ { "name": "cargo invocation is the cargo command", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "invocationPlan", "request": { @@ -35,7 +35,7 @@ }, { "name": "cargo version meets the minimum", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "checkToolVersion", "request": { @@ -51,7 +51,7 @@ }, { "name": "cargo version below the minimum", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "checkToolVersion", "request": { @@ -67,7 +67,7 @@ }, { "name": "cargo build puts options after the subcommand", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -107,7 +107,7 @@ }, { "name": "cargo example needs its target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -143,7 +143,7 @@ }, { "name": "cargo example refuses a binary target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -170,7 +170,7 @@ }, { "name": "cargo example refuses the project scope", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -191,7 +191,7 @@ }, { "name": "cargo test selects one exact case", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -223,7 +223,7 @@ }, { "name": "cargo test selects an integration target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -263,7 +263,7 @@ }, { "name": "cargo test refuses a binary target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -289,7 +289,7 @@ }, { "name": "cargo test cannot select a suite", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -310,7 +310,7 @@ }, { "name": "cargo options keep known values and drop the rest", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "resolveOptions", "request": { @@ -382,7 +382,7 @@ }, { "name": "cargo options accept model values before a model exists", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "resolveOptions", "request": { @@ -433,7 +433,7 @@ }, { "name": "cargo short diagnostics", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "decodeDiagnostics", "request": { @@ -463,7 +463,7 @@ }, { "name": "go runs through the host-managed runtime", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "invocationPlan", "request": { @@ -503,7 +503,7 @@ }, { "name": "go runtime version", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "checkToolVersion", "request": { @@ -520,7 +520,7 @@ }, { "name": "go runtime below the minimum", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "checkToolVersion", "request": { @@ -537,7 +537,7 @@ }, { "name": "go model is a json stream on stdout", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "modelPlan", "request": { @@ -566,7 +566,7 @@ }, { "name": "go list output becomes projects, targets, and run entries", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeModel", "request": { @@ -680,7 +680,7 @@ }, { "name": "go dependencies of a module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "dependencyPlan", "request": { @@ -714,7 +714,7 @@ }, { "name": "go module list skips the main module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeDependencies", "request": { @@ -748,7 +748,7 @@ }, { "name": "go test escapes the case name", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "testPlan", "request": { @@ -786,7 +786,7 @@ }, { "name": "go diagnostics relative to the module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeDiagnostics", "request": { @@ -815,7 +815,7 @@ }, { "name": "gradle prefers the unix wrapper", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "invocationPlan", "request": { @@ -874,7 +874,7 @@ }, { "name": "gradle prefers the batch wrapper on windows", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "invocationPlan", "request": { @@ -933,7 +933,7 @@ }, { "name": "gradle two-part version", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "checkToolVersion", "request": { @@ -949,7 +949,7 @@ }, { "name": "gradle model runs the init script into the output file", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -987,7 +987,7 @@ }, { "name": "gradle model needs an output file", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1004,7 +1004,7 @@ }, { "name": "gradle build of one project", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1041,7 +1041,7 @@ }, { "name": "gradle build of the whole build", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1066,7 +1066,7 @@ }, { "name": "gradle model task runs by its path", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1106,7 +1106,7 @@ }, { "name": "gradle arbitrary tasks need arguments", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1138,7 +1138,7 @@ }, { "name": "gradle build takes no user arguments", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1158,7 +1158,7 @@ }, { "name": "gradle test of one method", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1199,7 +1199,7 @@ }, { "name": "gradle test refuses a case without its suite", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1223,7 +1223,7 @@ }, { "name": "gradle workspace test collects every model project's reports", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1259,7 +1259,7 @@ }, { "name": "gradle dependencies of the root project", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1293,7 +1293,7 @@ }, { "name": "gradle javac and kotlin diagnostics", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "decodeDiagnostics", "request": { @@ -1328,7 +1328,7 @@ }, { "name": "maven package of a nested module", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1373,7 +1373,7 @@ }, { "name": "maven clean of the reactor root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1408,7 +1408,7 @@ }, { "name": "maven goal of a reactor below the workspace root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1443,7 +1443,7 @@ }, { "name": "maven test of one method", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "testPlan", "request": { @@ -1481,7 +1481,7 @@ }, { "name": "maven test refuses a case without its suite", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "testPlan", "request": { @@ -1498,7 +1498,7 @@ }, { "name": "maven dependency tree of a nested module", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "dependencyPlan", "request": { @@ -1552,7 +1552,7 @@ }, { "name": "maven dependency tree of the reactor root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "dependencyPlan", "request": { @@ -1592,7 +1592,7 @@ }, { "name": "maven compiler diagnostics", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "decodeDiagnostics", "request": { @@ -1622,7 +1622,7 @@ }, { "name": "unknown build system", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1639,7 +1639,7 @@ }, { "name": "gradle provider model is a request for the provider with the option values", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1676,7 +1676,7 @@ }, { "name": "gradle provider model covers the whole instance", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1696,7 +1696,7 @@ }, { "name": "gradle provider dependencies of a project name the project", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1737,7 +1737,7 @@ }, { "name": "gradle provider model reload carries refresh and the resolved tool", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1782,7 +1782,7 @@ }, { "name": "gradle provider model without a reload leaves refresh out", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1820,7 +1820,7 @@ }, { "name": "gradle provider dependency reload carries refresh and the resolved tool", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1869,7 +1869,7 @@ }, { "name": "gradle provider tool must be an absolute path", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1891,7 +1891,7 @@ }, { "name": "gradle model command ignores the provider refresh and tool", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1933,7 +1933,7 @@ }, { "name": "gradle provider model result becomes projects, tasks, and run entries", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeModel", "request": { @@ -2079,7 +2079,7 @@ }, { "name": "gradle provider model needs the provider's result", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeModel", "request": { @@ -2096,7 +2096,7 @@ }, { "name": "gradle provider dependency result is a litheDependencies tree", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeDependencies", "request": { @@ -2149,7 +2149,7 @@ }, { "name": "gradle provider dependency result of another format is refused", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeDependencies", "request": { @@ -2205,7 +2205,7 @@ }, { "name": "mix model goes to the shared language server", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "modelPlan", "request": { @@ -2235,7 +2235,7 @@ }, { "name": "mix provider lists dependencies of the workspace only", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "dependencyPlan", "request": { @@ -2255,7 +2255,7 @@ }, { "name": "mix dependencies of the workspace", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "dependencyPlan", "request": { @@ -2286,7 +2286,7 @@ }, { "name": "maven accepts Java 8 stderr with environment options", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "checkToolVersion", "request": { @@ -2303,7 +2303,7 @@ }, { "name": "maven rejects Java 7 against minimum 8", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "checkToolVersion", "request": { @@ -2320,7 +2320,7 @@ }, { "name": "gradle reports Java 8 as below minimum 17", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "checkToolVersion", "request": { diff --git a/shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json similarity index 93% rename from shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json index 9f963638c0..39967483b8 100644 --- a/shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.mix-provider", - "displayName": "Mix Provider Fixture", + "id": "dev.lithe.fixture.build-provider-language-server", + "displayName": "Build Provider With Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-provider.plugin.json index 68e352a324..512f7010f8 100644 --- a/shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-provider.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-provider", - "displayName": "Gradle Provider Fixture", + "id": "dev.lithe.fixture.build-provider", + "displayName": "Build Provider Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1, "settings": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json index c8b1f7ab45..7844177174 100644 --- a/shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.cargo-build", - "displayName": "Cargo Build Fixture", + "id": "dev.lithe.fixture.build-static-file", + "displayName": "Static File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/command-plans.json b/shared/contracts/plugins/v1/fixtures/command-plans.json index dd3c9afba9..5c1994c4b5 100644 --- a/shared/contracts/plugins/v1/fixtures/command-plans.json +++ b/shared/contracts/plugins/v1/fixtures/command-plans.json @@ -4,7 +4,7 @@ "cases": [ { "name": "php run passes user arguments after the file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "public/index.php", "arguments": ["--verbose", "two words"] }, @@ -15,7 +15,7 @@ }, { "name": "php run protects a file name that looks like an option", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "-weird.php" }, @@ -26,7 +26,7 @@ }, { "name": "php run keeps surrounding spaces in a file name", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": " spaced.php" }, @@ -37,7 +37,7 @@ }, { "name": "php run refuses a path outside the workspace", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "../outside.php" }, @@ -45,7 +45,7 @@ }, { "name": "php discovery lists the workspace then sorted test files", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["tests/StoreTest.php", "composer.json", "src/Store.php", "tests/Helpers.php", "tests/LegacyTEST.php"] }, @@ -60,7 +60,7 @@ }, { "name": "php discovery returns nothing outside a PHP project", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["README.md", "index.php", "tests/IndexTest.php"] }, @@ -68,7 +68,7 @@ }, { "name": "php workspace test", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "workspace" }, "relativeProjectFilePaths": ["phpunit.xml.dist"] }, @@ -79,7 +79,7 @@ }, { "name": "php file test", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "file", "relativeFilePath": "tests/StoreTest.php" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -90,7 +90,7 @@ }, { "name": "php single test case with its file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": " testKeepsInsertionOrder ", "relativeFilePath": "tests/StoreTest.php" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -101,7 +101,7 @@ }, { "name": "php single test case escapes regex characters and omits the missing file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "testA.b/c" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -112,7 +112,7 @@ }, { "name": "php blank test identifier", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": " " }, "relativeProjectFilePaths": ["composer.json"] }, @@ -120,7 +120,7 @@ }, { "name": "php test outside a PHP project", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "workspace" }, "relativeProjectFilePaths": ["index.php"] }, @@ -128,7 +128,7 @@ }, { "name": "language without an execution provider", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "python", "operation": "runPlan", "request": { "relativeFilePath": "main.py" }, @@ -136,7 +136,7 @@ }, { "name": "go run uses the project toolchain", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "runPlan", "request": { "relativeFilePath": "cmd/app/main.go", "arguments": ["-port", "8080"] }, @@ -147,7 +147,7 @@ }, { "name": "go discovery", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["go.mod", "main.go", "pkg/store/store_test.go", "main_test.go"] }, @@ -162,7 +162,7 @@ }, { "name": "go file test runs the file's package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "file", "relativeFilePath": "pkg/store/store_test.go" }, "relativeProjectFilePaths": ["go.mod"] }, @@ -173,7 +173,7 @@ }, { "name": "go test case in a root file targets the root package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "TestMain", "relativeFilePath": "main_test.go" }, "relativeProjectFilePaths": ["go.work"] }, @@ -184,7 +184,7 @@ }, { "name": "go test case without a file searches every package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "TestStore/sub(case)" }, "relativeProjectFilePaths": ["go.mod"] }, @@ -195,7 +195,7 @@ }, { "name": "java launch pins the packaged runtime and lists the jdt integration resources", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -252,7 +252,7 @@ }, { "name": "java launch selects per-architecture package paths and drops a trailing separator", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -579,7 +579,7 @@ }, { "name": "php launch keeps API 1 arguments literal and names the packaged executable", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "languageServerLaunchPlan", "request": { @@ -613,7 +613,7 @@ }, { "name": "go launch resolves a system command and needs nothing from the package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "languageServerLaunchPlan", "request": { @@ -647,7 +647,7 @@ }, { "name": "launch plan refuses an unknown architecture", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -663,7 +663,7 @@ }, { "name": "launch plan refuses an empty plugin directory", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -679,7 +679,7 @@ }, { "name": "launch plan refuses a language without a server", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "kotlin", "operation": "languageServerLaunchPlan", "request": { @@ -695,7 +695,7 @@ }, { "name": "gradle provider launch is a java runtime launch selected by build system", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "languageServerLaunchPlan", "request": { @@ -757,7 +757,7 @@ }, { "name": "mix provider launch is the elixir language server plan and names that server", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "languageServerLaunchPlan", "request": { @@ -793,7 +793,7 @@ }, { "name": "elixir language server launch carries no build provider", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "languageID": "elixir", "operation": "languageServerLaunchPlan", "request": { @@ -823,7 +823,7 @@ }, { "name": "launch plan refuses a build system without a provider", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "languageServerLaunchPlan", "request": { @@ -839,7 +839,7 @@ }, { "name": "launch plan refuses an undeclared build system", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "maven", "operation": "languageServerLaunchPlan", "request": { diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index e87056a025..16d371acc3 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -4,133 +4,133 @@ "cases": [ { "name": "legacy native manifest schema", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/schemaVersion", "value": 1 }], "expectedCode": "plugin.schema.unsupported" }, { "name": "future plugin API", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/pluginAPIVersion", "value": 99 }], "expectedCode": "plugin.api.unsupported" }, { "name": "unknown top-level field", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/entrypoint", "value": { "kind": "nativeBundle" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "uppercase plugin id", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/id", "value": "Dev.Lithe.Fixture" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged language server escapes the package", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/path", "value": "../bin/intelephense" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "system command is a path", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/commands", "value": ["/usr/local/bin/gopls"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "language server for an undeclared language", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/languageID", "value": "hack" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "unsupported runtime dependency", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "python" }] }], "expectedCode": "plugin.runtime.unsupported" }, { "name": "unknown permission", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/permissions/0", "value": "network.fetch" }], "expectedCode": "plugin.permission.unknown" }, { "name": "language server without its permission", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": ["process.run", "process.test"] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "missing activation event", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onLanguage:php", "onRun:php"] }], "expectedCode": "plugin.activation.invalid" }, { "name": "activation event without a provider", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "remove", "path": "/executionProviders/0/run" }], "expectedCode": "plugin.activation.invalid" }, { "name": "unknown template variable", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/arguments", "value": ["${workspaceRoot}/${file}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments embedded in an element", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/arguments", "value": ["${file}", "--args=${userArguments}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case uses the file without a guard", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/test/testCaseArguments", "value": ["vendor/bin/phpunit", "--filter", "${testName:regex}", "${file}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case never names the test", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/test/testCaseArguments", "value": ["vendor/bin/phpunit"] }], "expectedCode": "plugin.template.invalid" }, { "name": "executable is an absolute path", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/executable", "value": { "command": "/usr/bin/php" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime launch in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch", "value": { "kind": "runtime", "tool": "node", "entry": "language-server/lib/intelephense.js" } }, { "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "initialization options in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/initializationOptions", "value": { "storagePath": "cache" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "host integration in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration", "value": { "id": "jdt" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime dependency states a minimum version", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "node", "minimumVersion": "18" }] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "java runtime in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "java" }] }], "expectedCode": "plugin.runtime.unsupported" }, @@ -166,19 +166,19 @@ }, { "name": "packaged runtime escapes the package", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/packagedRuntime", "value": "../jdk/bin/java" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged runtime varies by something other than the architecture", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/packagedRuntime", "value": "${stateDir}/jdk/bin/java" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged runtime also states a minimum version", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/minimumVersion", "value": "21" }], "expectedCode": "plugin.manifest.invalid" }, @@ -226,61 +226,61 @@ }, { "name": "unknown host integration", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/id", "value": "roslyn" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "host integration requested outside the official namespace", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/id", "value": "io.example.java" }, { "op": "set", "path": "/vendor/signatureRequirement", "value": "none" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "jdt integration without a java runtime launch", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch", "value": { "kind": "packaged", "path": "language-server/bin/jdtls" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "jdt integration misses a required resource", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/languageServers/0/hostIntegration/resources/configuration" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "jdt integration declares a resource it does not use", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/indexer", "value": "language-server/indexer.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration resource leaves the package", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/lombokAgent", "value": "../lombok.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration resource list given as a single path", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/extensionBundleDirectories", "value": "language-server/java-debug" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability the integration does not provide", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/capabilities", "value": ["formatting"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability without the resource its host code reads", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/languageServers/0/hostIntegration/resources/testRunner" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability that the plugin's execution provider already provides", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [ { "op": "set", "path": "/permissions", "value": ["languageServer.launch", "process.run"] }, { "op": "set", "path": "/activationEvents", "value": ["onLanguage:java", "onRun:java"] }, @@ -295,7 +295,7 @@ ], "expectedCode": "plugin.manifest.invalid" }, - {"name": "settings on API level 1", "base": "php-support.plugin.json", "edits": [{"op": "set", "path": "/settings", "value": [{"id": "x", "type": "boolean", "title": "X", "default": true}]}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "settings on API level 1", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/settings", "value": [{"id": "x", "type": "boolean", "title": "X", "default": true}]}], "expectedCode": "plugin.manifest.invalid"}, {"name": "launch template names an undeclared setting", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/arguments/1", "value": "--log-level=${setting:verbosity}"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting shapes a package path", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/launch/runtimeArguments/1", "value": "--require=${pluginDir}/language-server/${setting:logLevel}.js"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting identifier with a separator", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/settings/0/id", "value": "heap-mb"}], "expectedCode": "plugin.manifest.invalid"}, @@ -449,7 +449,7 @@ }, { "name": "build systems need plugin API 3", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/pluginAPIVersion", "value": 2 }, { "op": "remove", "path": "/requires" } @@ -458,25 +458,25 @@ }, { "name": "build systems need the build permission", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": [] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "build systems need their activation event", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": [] }], "expectedCode": "plugin.activation.invalid" }, { "name": "build activation names an undeclared build system", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onBuildSystem:cargo", "onBuildSystem:bazel"] }], "expectedCode": "plugin.activation.invalid" }, { "name": "build system ID is not an identifier", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/buildSystems/0/id", "value": "Cargo" }, { "op": "set", "path": "/activationEvents", "value": ["onBuildSystem:Cargo"] } @@ -485,7 +485,7 @@ }, { "name": "host integration is reserved to official plugins", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [ { "op": "set", "path": "/id", "value": "org.example.maven-build" }, { "op": "set", "path": "/vendor/id", "value": "org.example" } @@ -494,97 +494,97 @@ }, { "name": "host integration the host does not provide", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/hostIntegration", "value": "sbt" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "host-integration model without a host integration", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/hostIntegration" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option arguments placed twice", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/arguments", "value": ["build", "${optionArguments}", "${optionArguments:release}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "option arguments name an undeclared option", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/2/arguments", "value": ["clean", "${optionArguments:profile}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "target variable without its guard", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/arguments", "value": ["test", "${target.name}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case variable without its guard", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/arguments", "value": ["test", "--", "${test.case}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test variables outside a test command", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/arguments", "value": ["build", "${test.case}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments in a model command", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/arguments", "value": ["list", "-json", "${userArguments}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "output file without the file channel", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/model/output" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "file channel without the output file", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/arguments", "value": ["-q", "litheModel"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin directory in a task", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/1/arguments", "value": ["--init-script", "${pluginDir}/x.gradle", "build"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments required but never placed", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/4/arguments", "value": ["validate"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "run entries from a task that is not a model template", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/run/0/fromTasks/templates", "value": ["build"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "host-managed runtime with its own executables", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies/0/executables", "value": [{ "kind": "command", "command": "go" }] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime executable without its dependency", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin-described runtime without executables", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [ { "op": "set", "path": "/buildSystems/0/invocation/executables/0/runtime", "value": "zig" }, { "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies/0/id", "value": "zig" } @@ -593,229 +593,229 @@ }, { "name": "environment executable not declared as an input", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/environmentInputs", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "project-file executable leaves the root", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/executables/0/path", "value": "../gradlew" }], "expectedCode": "plugin.path.invalid" }, { "name": "version pattern without a capture group", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/version/pattern", "value": "cargo \\d+" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "minimum version without a probe", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/invocation/version" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "static model file leaves the root", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/file", "value": "../Cargo.toml" }], "expectedCode": "plugin.path.invalid" }, { "name": "selector without a pointer", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/name", "value": "name" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "literal field with a control character", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/contentRoots/0/path", "value": { "value": "src\n" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "predicate with both equals and exists", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/map/where/0/equals", "value": true }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a static model in a format the map does not read", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/decoder", "value": "yaml" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model command printing a single JSON document reads it as jsonStream", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/decoder", "value": "json" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model map lists no tasks", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/tasks", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model map has no top-level targets", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/targets", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a project rule names no parent", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/parent", "value": "/Dir" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "target tags are a literal list", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/targets/0/tags", "value": { "from": "/kind", "map": { "bin": ["application"] } } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "lastSegment is the only transform", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/targets/0/properties/short/transform", "value": "parentDirectory" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a dependency map is flat", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/map/children", "value": "/Deps/*" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "layout lists only excluded directories", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/layout/generated", "value": ["target/generated"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "diagnostic capture group beyond the pattern", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/diagnostics/patterns/0/message", "value": 9 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option environment name is not uppercase", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/environment", "value": { "cargo_release": "1" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "sync watch leaves the root", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/sync/watch", "value": ["../Cargo.toml"] }], "expectedCode": "plugin.path.invalid" }, { "name": "test target tags without the target scope", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/scopes", "value": ["project"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider that no source uses", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider", "value": { "launch": { "kind": "systemCommand", "commands": ["gradle-provider"] }, "executesProjectCode": true, "mayUseNetwork": true } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider source without a provider", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/provider" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "shared provider names a language without a language server", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/languageServer", "value": "erlang" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider mixes the shared and own forms", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch", "value": { "kind": "systemCommand", "commands": ["mix-provider"] } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider without its execution flags", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/provider/executesProjectCode" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider without the language server permission", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": ["process.build"] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "provider entry leaves the package", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch/entry", "value": "../provider.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider runtime the host cannot start", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch/tool", "value": "go" }], "expectedCode": "plugin.runtime.unsupported" }, { "name": "provider argument names an undeclared setting", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/arguments", "value": ["--heap=${setting:missing}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "provider dependencies of a target", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/scopes", "value": ["target"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin API 3 without requires", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "remove", "path": "/requires" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "requires below plugin API 3", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/requires", "value": { "languageServers": 1, "languages": 1, "serverProcess": 1 } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "empty requires", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires", "value": {} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "capability version is not a positive integer", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 0 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "malformed capability ID", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/build-systems", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "future capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/debuggers", "value": 1 }], "expectedCode": "plugin.api.unsupported" }, { "name": "future capability is reported before its unknown section", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/requires/debuggers", "value": 1 }, { "op": "set", "path": "/debuggers", "value": [{ "id": "lldb" }] } @@ -824,43 +824,43 @@ }, { "name": "future capability version", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 3 }], "expectedCode": "plugin.api.unsupported" }, { "name": "used capability missing from requires", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "remove", "path": "/requires/languageServers" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "requires lists an unused capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/ui", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider without buildProviders capability", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/requires/buildProviders" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "shared provider without buildProviders capability", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "remove", "path": "/requires/buildProviders" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "template build lists unused buildProviders capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildProviders", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider process without serverProcess", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], "expectedCode": "plugin.manifest.invalid" }, @@ -920,43 +920,43 @@ }, { "name": "build system icon leaves the package", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/icon", "value": {"path": "../gradle.svg"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "toolbar option without an icon", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "toolbar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "toolbar option that is not boolean", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "toolbar" }, { "op": "set", "path": "/buildSystems/0/options/0/icon", "value": {"host": "settings"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "tree option that is not multiSelect", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "tree" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option icon outside the toolbar", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/icon", "value": {"host": "settings"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "task category deeper than four headings", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["a", "b", "c", "d", "e"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "task category with an empty heading", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["Tasks", " "] }], "expectedCode": "plugin.manifest.invalid" } diff --git a/shared/contracts/plugins/v1/fixtures/java-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json similarity index 93% rename from shared/contracts/plugins/v1/fixtures/java-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json index bfae664daa..5a2ec18a5d 100644 --- a/shared/contracts/plugins/v1/fixtures/java-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.java-support", - "displayName": "Java Support Fixture", + "id": "dev.lithe.fixture.language-host-integration", + "displayName": "Host Integration Language Server Fixture", "version": "1.0.0", "pluginAPIVersion": 2, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/php-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json similarity index 94% rename from shared/contracts/plugins/v1/fixtures/php-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json index d0abff123f..30aacbd2e3 100644 --- a/shared/contracts/plugins/v1/fixtures/php-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.php-support", - "displayName": "PHP Support Fixture", + "id": "dev.lithe.fixture.language-packaged-server", + "displayName": "Packaged Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 1, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/go-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json similarity index 95% rename from shared/contracts/plugins/v1/fixtures/go-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json index a37203487d..299f341b1e 100644 --- a/shared/contracts/plugins/v1/fixtures/go-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.go-support", - "displayName": "Go Support Fixture", + "id": "dev.lithe.fixture.language-system-command", + "displayName": "System Command Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 1, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json index bee4b09299..c4f5960bfa 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json @@ -8,7 +8,7 @@ "platform": "darwin-arm64", "expected": { "plugins": [ - { "id": "dev.lithe.fixture.php-support", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "available", "version": "1.0.0", "sha256": "5555555555555555555555555555555555555555555555555555555555555555" } }, + { "id": "dev.lithe.fixture.language-packaged-server", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "available", "version": "1.0.0", "sha256": "5555555555555555555555555555555555555555555555555555555555555555" } }, { "id": "io.example.elixir-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.future-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "linux-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.zig-support", "latestVersion": "2.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "available", "version": "2.0.0", "downloadURL": "https://github.com/example/lithe-zig/releases/download/v2.0.0/zig-support-2.0.0-darwin-arm64.zip" } } @@ -29,7 +29,7 @@ { "id": "io.example.zig-support", "availability": { "status": "unsupported", "code": "plugin.index.platformUnsupported", "platform": "darwin-x64" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -45,7 +45,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0", "sha256": "3333333333333333333333333333333333333333333333333333333333333333" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -62,7 +62,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -79,7 +79,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -96,7 +96,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0", "sha256": "3333333333333333333333333333333333333333333333333333333333333333" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index.json b/shared/contracts/plugins/v1/fixtures/marketplace/index.json index b4ca8a7a4e..640b5ec3ff 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index.json @@ -45,8 +45,8 @@ ] }, { - "id": "dev.lithe.fixture.php-support", - "name": "PHP Support Fixture", + "id": "dev.lithe.fixture.language-packaged-server", + "name": "Packaged Language Server Fixture", "author": "Lithe", "repository": "https://github.com/Lithe-IDEA/Lithe-IDEA", "description": "PHP language server, run and test support.", diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json index 2d50fa7f70..0663067507 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json @@ -4,8 +4,8 @@ "cases": [ { "name": "first installation lists every program", - "base": "php-support.plugin.json", - "expectedID": "dev.lithe.fixture.php-support", + "base": "language-packaged-server.plugin.json", + "expectedID": "dev.lithe.fixture.language-packaged-server", "expectedVersion": "0.1.0", "expected": { "permissions": [ @@ -67,7 +67,7 @@ }, { "name": "unchanged update needs no new approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -75,14 +75,14 @@ "value": "0.2.0" } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 0 } }, { "name": "changed language server arguments need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -93,7 +93,7 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newPrograms": [ { @@ -112,7 +112,7 @@ }, { "name": "changed run arguments with the same executable need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -123,7 +123,7 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newPrograms": [ { @@ -143,7 +143,7 @@ }, { "name": "changed test arguments with the same executable need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -153,14 +153,14 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 1 } }, { "name": "changed language server validation probe needs approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -170,21 +170,21 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 1 } }, { "name": "package does not match the index entry", - "base": "php-support.plugin.json", - "expectedID": "dev.lithe.fixture.php-support", + "base": "language-packaged-server.plugin.json", + "expectedID": "dev.lithe.fixture.language-packaged-server", "expectedVersion": "9.9.9", "expectedCode": "plugin.package.mismatch" }, { "name": "unsigned package in the official namespace", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [ { "op": "set", @@ -196,7 +196,7 @@ }, { "name": "unsigned community package", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [ { "op": "set", @@ -271,19 +271,19 @@ }, { "name": "template build review does not require buildProviders", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "hostCapabilities": { "buildSystems": [ 1 ] }, "expected": { - "id": "dev.lithe.fixture.cargo-build" + "id": "dev.lithe.fixture.build-static-file" } }, { "name": "gradle provider review requires buildProviders even with buildSystems", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildSystems": [ 1 @@ -306,7 +306,7 @@ }, { "name": "gradle provider review rejects unsupported buildProviders version", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ 2 @@ -332,14 +332,14 @@ }, { "name": "gradle provider review without a host table uses Core capabilities", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "expected": { - "id": "dev.lithe.fixture.gradle-provider" + "id": "dev.lithe.fixture.build-provider" } }, { "name": "mix provider review requires buildProviders even with buildSystems", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildSystems": [ 1 @@ -362,7 +362,7 @@ }, { "name": "mix provider review rejects unsupported buildProviders version", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildProviders": [ 2 @@ -388,14 +388,14 @@ }, { "name": "mix provider review without a host table uses Core capabilities", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "expected": { - "id": "dev.lithe.fixture.mix-provider" + "id": "dev.lithe.fixture.build-provider-language-server" } }, { "name": "own provider review accepts implemented buildProviders", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ 1 @@ -411,21 +411,21 @@ ] }, "expected": { - "id": "dev.lithe.fixture.gradle-provider" + "id": "dev.lithe.fixture.build-provider" } }, { "name": "host without build systems refuses a build plugin", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "languages": [1], "languageServers": [1], "serverProcess": [1] }, "expectedCode": "plugin.api.unsupported" }, { "name": "host with every required capability reviews a build plugin", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildProviders": [1], "buildSystems": [1], "languages": [1], "languageServers": [1], "serverProcess": [1] }, "expected": { - "id": "dev.lithe.fixture.mix-provider", + "id": "dev.lithe.fixture.build-provider-language-server", "permissions": ["languageServer.launch", "process.build"], "newProgramCount": 2 } diff --git a/shared/contracts/plugins/v1/plugin-index.schema.json b/shared/contracts/plugins/v1/plugin-index.schema.json index 4d4f53af56..26ae06a5d4 100644 --- a/shared/contracts/plugins/v1/plugin-index.schema.json +++ b/shared/contracts/plugins/v1/plugin-index.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://lithe.dev/contracts/plugins/v1/plugin-index.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-index.schema.json", "title": "Lithe plugin marketplace index (plugin contract v1)", "description": "Shape of a marketplace source index. Semantic rules that JSON Schema cannot express (download URLs inside the entry's own repository, duplicate IDs and versions, the official namespace, platform and API filtering) are defined in README.md and enforced by plugins.marketplace.validateIndex.", "type": "object", diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 04634debf2..3571dd1a21 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://lithe.dev/contracts/plugins/v1/plugin-manifest.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-manifest.schema.json", "title": "Lithe declarative plugin manifest (plugin contract v1)", "description": "Shape of plugin.json for a declarative plugin. Semantic rules that JSON Schema cannot express (cross references, permissions, activation events, template variables) are defined in README.md and enforced by plugins.declarative.validate.", "type": "object", diff --git a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json index b37d7040b1..4896e19501 100644 --- a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json +++ b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json @@ -15,8 +15,7 @@ "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", - "macos/Tests/LitheTests/PluginWebBridgeTests.swift", - "sdk/node/src/index.d.ts" + "macos/Tests/LitheTests/PluginWebBridgeTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index e6b33c26de..b2bc106b4f 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -37,11 +37,11 @@ "evidence": [ "rust/lithe-core/src/build", "shared/contracts/plugins/v1/fixtures/build-plans.json", - "shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json" + "shared/contracts/plugins/v1/fixtures/build-provider.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 cargo-build、go-build 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;gradle-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,mix-provider 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" } diff --git a/shared/platform-feature-matrix/features/go-support-plugin.json b/shared/platform-feature-matrix/features/go-support-plugin.json index a6cc660ff7..3bf0e78ab2 100644 --- a/shared/platform-feature-matrix/features/go-support-plugin.json +++ b/shared/platform-feature-matrix/features/go-support-plugin.json @@ -20,7 +20,7 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/fixtures/go-support.plugin.json" + "shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" diff --git a/shared/platform-feature-matrix/features/java-support-plugin.json b/shared/platform-feature-matrix/features/java-support-plugin.json index b8224fd67f..aad20b01e1 100644 --- a/shared/platform-feature-matrix/features/java-support-plugin.json +++ b/shared/platform-feature-matrix/features/java-support-plugin.json @@ -12,7 +12,7 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", "macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift", "macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift", - "shared/contracts/plugins/v1/fixtures/java-support.plugin.json", + "shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json", "macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift", "macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift", "macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift" diff --git a/shared/platform-feature-matrix/features/plugin-functions.json b/shared/platform-feature-matrix/features/plugin-functions.json index 43b5620a8b..77f40eb887 100644 --- a/shared/platform-feature-matrix/features/plugin-functions.json +++ b/shared/platform-feature-matrix/features/plugin-functions.json @@ -2,7 +2,7 @@ "id": "plugin-functions", "area": "扩展", "group": "插件与主题", - "capability": "插件私有函数:插件后端通过 litheFunctions v1 协商和注册,宿主按插件归属调用,接收结果与进度,并支持取消、超时和撤回;领域操作仍使用类型化契约;官方 Node 与 Java(Java 8+)后端 SDK 实现后端一侧", + "capability": "插件私有函数:插件后端通过 litheFunctions v1 协商和注册,宿主按插件归属调用,接收结果与进度,并支持取消、超时和撤回;领域操作仍使用类型化契约;官方 Node 与 Java(Java 8+)后端 SDK(在 lithe-plugin-docs 仓库)实现后端一侧", "macos": { "implementationStatus": "partial", "verificationStatus": "verified", @@ -10,10 +10,6 @@ "rust/lithe-core/src/lsp/interface/plugin_function.rs", "rust/lithe-core/src/lsp/interface/engine.rs", "shared/contracts/plugins/v1/fixtures/function-protocol.json", - "sdk/node/src/backend.mjs", - "rust/lithe-core/tests/plugin_sdk_node.rs", - "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java", - "rust/lithe-core/tests/plugin_sdk_java.rs", "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift", "macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift", @@ -27,12 +23,10 @@ "evidence": [ "rust/lithe-core/src/lsp/interface/plugin_function.rs", "rust/lithe-core/src/lsp/interface/engine.rs", - "shared/contracts/plugins/v1/README.md", - "sdk/node/src/backend.mjs", - "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java" + "shared/contracts/plugins/v1/README.md" ] }, "owner": "Extensions", - "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 Node SDK:node --test sdk/node/test/*.test.mjs,并以 LITHE_PLUGIN_SDK_NODE 指向 Node 运行 cargo test -p lithe-core --test plugin_sdk_node,示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变。 官方 Java SDK:mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试),并设置 LITHE_PLUGIN_SDK_JAVA、LITHE_PLUGIN_SDK_JAVA_SAMPLE、LITHE_PLUGIN_SDK_NODE 运行 cargo test -p lithe-core --test plugin_sdk_java,Java 示例按插件打包形态跑与 Node 相同的 Core 场景。", - "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 sdk/node/examples/sample-build(PR #1365,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 尚无插件后端宿主;不能以共享测试通过替代平台功能验收。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。" + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 SDK(在 lithe-plugin-docs 仓库中运行):node --test sdk/node/test/*.test.mjs;mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试);node scripts/sdk-e2e.mjs 把 sdk/e2e 复制进契约副本锁定的主仓库 commit 并运行 plugin_sdk_node、plugin_sdk_java,两个示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变,Java 示例按插件打包形态(plugin.json + shade 后的 JAR)运行;CI 每周再以 --ref HEAD 对主仓库最新 Core 运行一次。", + "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 Node SDK 的示例插件 sample-build(PR #1365,当时在主仓库 sdk/node/examples,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 尚无插件后端宿主;不能以共享测试通过替代平台功能验收。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(lithe-plugin-docs 的 sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(lithe-plugin-docs 的 sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。" } From c1a1f98d0e2f78057c6e60800759b70bcd8ece56 Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:03:09 +0800 Subject: [PATCH 06/50] fix(macos): keep marketplace details inside split bounds (#1447) --- .../feature/2026-10-12-plugin-marketplace.md | 12 ++ .../plugin-marketplace-layout/after-dark.png | Bin 0 -> 181659 bytes .../plugin-marketplace-layout/after-light.png | Bin 0 -> 177464 bytes .../plugin-marketplace-layout/before-dark.png | Bin 0 -> 240081 bytes .../before-light.png | Bin 0 -> 232119 bytes .../plugin-marketplace-layout-visual-check.md | 40 ++++ .../Views/App/PluginMarketplaceView.swift | 43 +++-- .../PluginMarketplaceLayoutTests.swift | 174 ++++++++++++++++++ .../features/plugin-marketplace.json | 5 +- 9 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 docs/assets/plugin-marketplace-layout/after-dark.png create mode 100644 docs/assets/plugin-marketplace-layout/after-light.png create mode 100644 docs/assets/plugin-marketplace-layout/before-dark.png create mode 100644 docs/assets/plugin-marketplace-layout/before-light.png create mode 100644 docs/plugin-marketplace-layout-visual-check.md create mode 100644 macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift diff --git a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md index d90ec7910e..c9df1e6d1b 100644 --- a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md +++ b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md @@ -59,6 +59,17 @@ Lithe 的插件管理改成和 IntelliJ IDEA 一样的“插件市场 / 已安 **展示的版本。** Core 会跳过当前平台或插件 API 不满足的新版本,选择能安装的旧版本。详情里的版本、发布日期和支持平台都描述“点安装会装的那个版本”;最新版本不同时,单独一行注明它在当前 Lithe 或这台 Mac 上无法使用。 +**市场详情的分栏边界。** 市场和“已安装”使用不同的详情视图,必须分别验证。 +市场信息行和来源选择器有最小宽度;直接把它们放在纵向 ScrollView(滚动视口)里, +会让 SwiftUI 按内容宽度重新居中,拖窄后文字和图标就跨到分隔线左侧。 +市场视口现在先接受分栏给出的矩形,再以该宽度布局详情。正文最小宽度由现有 +标签列、间距、来源选择器和提示卡的尺寸推导;较窄时可横向滚动查看完整内容, +较宽时描述仍按可见宽度换行。短详情在高度变化后也保持顶部对齐。 +这里只测量视口的宽高,内容在测量闭包外构建;不在拖动中重读市场、重建模型或写偏好。 +正确做法是约束视口并保留内容的滚动可达性;不要只裁掉越界正文、把详情最小宽度 +传回外层撑宽设置窗口,或因为“已安装”测试通过就认为市场也通过。 +这是原生视图布局修改,没有新增运行时文件、缓存或安装资源写入。 + 正确做法:新增官方插件时登记到 `official-plugins.json`,改插件内容时同时升 `plugin.json` 的版本。 不要这样做:手工改 `index.json` 里的下载地址或 SHA-256,或者把插件包重新挂到 Lithe 的版本 Release 上。 @@ -93,6 +104,7 @@ Lithe 的插件管理改成和 IntelliJ IDEA 一样的“插件市场 / 已安 - `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib marketplace_plugins`:索引校验、平台与 API 判断、命名空间、源地址解析、安装审查的标准答案。 - `./scripts/verify-shared-contracts.sh`:市场 fixture 与错误码登记。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplaceLayoutTests|PluginDetailLayoutTests|PluginSettingsLayoutTests'`:市场用内存索引驱动真实页面,覆盖中英文描述、深浅主题、窗口宽高变化、原生分隔条拖动、释放后刷新不移位,以及窄视口横向滚动到正文末端;单独保留“已安装”详情和路径控件回归。可用 `LITHE_MARKETPLACE_LAYOUT_CAPTURES` 导出市场原生视图截图;测试拥有并清理自己的偏好域和窗口。 - `node --test scripts/official-plugin-releases.test.mjs`:只为版本变化的插件生成标签,内容变化未升版本时失败;已发布未登记的版本会被重新登记。 - `node scripts/test-reuse-worktree-resources.mjs`:磁盘快照、安装 staging、版本和审批记录不出现在可复用列表,显式选择时在访问工作树或复制前拒绝。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|MacPluginPackageDownloader|PluginManagementPresentation|PluginManager|PluginPackageStore'`:校验和先于解压、下载和展开上限、磁盘快照隔离、源与签名要求、完整程序审查、安装 staging/扫描/回滚摘要复查、来源保留及同 ID 不自动接管。过滤器只是运行入口,必须核对报告确实包含 `MacPluginDiskPackageImporterTests`、`PluginDiskInstallationTests` 和磁盘 FeatureModel 场景,不能以零匹配当作通过。 diff --git a/docs/assets/plugin-marketplace-layout/after-dark.png b/docs/assets/plugin-marketplace-layout/after-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ce6185aa219f33872ace7d4e4b48458b3bb484f5 GIT binary patch literal 181659 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

EH&&-^vq#quRtic(_ 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

qeS>6$3F_N}l{@VCiP(B>Rrx~VzQ5aJO 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

8`Vtq#~fO9R0J_Z)G%`9z^ zv?mVlf7%c=$#67HQNAn0@Cjm>QL}u(!$wb`oP<(w(4@1S8isO2vmjS<8$PU2bXT%E z&-laP9 z?tCvanJ`tgB-p-d3i9M>a=ZCl^AJ-0{(Nd(5al6#pGZ5|f41UCsg4k6Z*itQoTD9a z!^Qr1%V*YOcHfXIQ!%xIsDBHm&sZcu_9?@srI^t1WrG%LvObBnP@Oi89N!3e(GFG- zqy1y0@~DE2k>Rf642>vnGX`IcrfzUtg+2HC7ZGm~Iv{Y|R@07rJ&-EMZ;q+wj#fbM z3@1Wr&(f)JEVb6*b^{@&N{8nI=dvJZspnAFw2>%8tjKA)_h~lOrNWF(@Oc|j5z0(dt-|_f)uCw$~ zF3;GaqL2>sdPGv?Zq%RiqisdI$_O@CLfIYy@eXJwtw#-htfXA?ZDSYp81S(R_G+v$mI zt-)J$aPcy<#c}={e#wF*W@an{5q`T@Z06W?1oG}}knlgznaK-G)l?kQ(UbD9w9)dN zv**y5&GE91=?>PxR03H`v$r1O_l(9!Qu3^Zq>9O=_CNn>Q|{BIGrLMJmr&#t^&;3T z8mAPajp_yPnus9OQ*6FXGZx}-l*z_?8`st|Dw*uaPNU#C?R%Pl9r}u{Pa@JXv_yTX zg@T3xSt^JI2Nzcoo7PJLKYCDY<@Zs_6ywJn;rr^Ygv-UA@bPge5iGnG4sdx(v4_V* zWf`Y#e?-AC-)=>on%h2{U7P$A@pIU8_4A>=w}s|E7&7Edd`?cW`xT7jZKcP+8=o|2 z&6iQq#S-r8#T~-Q7NNNk=tzo79aJaTtQvW>XS@OMz&Tj1sI>xtCc5`3xhe#a1^sf1p` z4=1C|`DfR!bf0fywTy6kwx2hZ_7n%`C(zj@7aU#Z8|X(DU9Y~a$APyn?awoRi8T}N zuT~n&tP@J^KZ>M(M$YFw5)B5H|4I4t3k54ZoI*i39h-EJO<=6HO}47vOY}|JLJ5nV z$GE{G1gtAl{j2IWb_+(TgX25`7@2ppJ3oo9@Hd??V+1y=`t|h&HImyD{??JEHvfiM z#*|pw;ct~XqLK62JqDc{7ok_Ix2zDtvpd0a^`rAfIn^U>RSohGVxoiIgy7Xzc`SR7*1C@uB3Q$ z7<3u)tih(X|*l4mEc42(O6)yX`S{z6Jo@=|9&qQ`r09I$}g;fQzrq8YQB| z%PXO!E@V=8L&z+i6DNK{B#xqak{-aVGCfFp{Ks7-m+h@vq~%h6-0Du=wI_*)=hMM$Ps++4Pjtu-J`nv`%8n_mGeR$oUPAmHmPlUdwVcdey;aRNb)ry`!3#^ z7+iSiii!#(KWI`e(^ZJe`BR$H4FWv;L2!Oedq-ybk*t^T2NL9ppMkH&N!W%v*E;Xs z5IAp|fn)XQ_``5f?%0`nCOCvvKx8UI=B$AGns{yZJ=iZ-NkiV@-le;B30ekti~%k6FMM`*q{t5O zM5EV97d@9E5%8YZW?HkW_C1p4D=i{Pkh?v{seF2W4jy#voRq}TL;Ab$FFIeC*Nbr; z*;>~;Ak!!!-B4Yom%tv?e)hY569N&l`e z&<^I4Dj@=*YuY~!3e<}XE|rkrl?89r#zjeeqv1v1Z)RKmn(o77l?rH0$|#mbr=5oKDO%~tocrmtLGx8-cW+t>7F;o&Lrez^I+GIF zRmD|`S?DH4kSmgKlBge4FT!a(C7aU!6x$?-zdZt>PGbY+_?Y-C{))Yj_RAg_4JT2y zkeJzt5vdJd2Z(yJoXeXn_k;#77I&TbBsbbqg)r+l6{&Jvdz^6Gagk`va53bcb+49l z61fu1w;@9Dkf$t~|4?Rb-nDh4dB4aLz&n`!a9N167HifaBIGf!T)AiclVVsGVz=dx z>l|tu_v5nk?YjC_`B#quxL(*|v+kJC zb%me^tb_r|?3OXPyEk?y`l~(P$Wq1*s_bn(*9wXo7B)Hno+X)js)ug$5kyCn}w-ovB+SME7 zH)f5Nfo}j~*WTCbJV^wxct#Wtg8jX{QDn?Zn=Ajr*i}2n%B}X>`_@A8A6!U1+}6LJ z(US2DJ)e47W;(T$csD({O;3^1Qit3*MyNfW5Zar6@WWMIms1fr6S;XF1_81HASSlM z3=g(3R~Tt`VS>0BF4mkUNcp$utjmudsk}Z_7W|B)EQDye2e9LHEtm=9UAF$fz&D6` zr?aXD^CwW&m?ZGV%Wxti7G9vq%^bA=*f{NB@!0atCtVN_>bGuz8!Ld@0w!E@LXW|> z=vb0?JpxYRYB#5^dzkp!E2z^YXQ^T{IFG-1 z$3Y4tvwoCZ>2!q}{)aw3ozh(4oec(_+FBQE414FFs zX6$71gHGd0vVl1)4f~w-amhhW8iPAHqa+kv0Qyg>N?4HwhKM;* z3LQIErj9M|*7dA_#~HlZ9`X&*vd2VeAFsy(8*+YtO-x~~)PcEUtnTLxTH&mfo&h{pzHDpLf_!gBZKtW+QMqGQK(iJJP{ z+Ntk4XeI%TdB0;-?-s+0(;0#)amj7aEj)>uxzVL1bqi9FdH=caB}k@P6s$AZjrjGw zfFU*5l_-F-FyVSj7{UTVY26o2vBseJ%YYOCLDTaw%nx`pb?etu6u35Oe2g#O^~llm&C z6}-2FmDNgfgu1){kvVXL*8u715(<~}CltunF=KTqKHc?$-Xv0us^8Uk5oa-8bjaWP zA|Dx^9?y_Oca$sjS5i?rRx6*YxpEDiK= z{Y1fUAL8sdI3`0xi7Ufn%mQyB`F<8^arEzw!;FJ4k?bzAo$soxrkyDw8W zguHXkf9YxMV3o)#Ay!V!HMyFSaInJHu6Hz{MaP|WfCei-^D zg1a4sV+Tr}@wutQTz8vr_ekb=a;KVMFL69j>@K;o%kL26+P*@Pc^-s7SB~iv9yi)M zvfhTUT5G@I+g^4t+0BOM98Kd#?vwIqDHIDC9Ep*j-XArUrltM_=(fu@g(5k{`&q~J zte=gD$`{9KbxB05GuF=iOlHFPKZLzySX|lCHXI0nhTss~Y1|3!PU8d(9$bRE1b5fq z?h@Rc1b26r;GPBo1o)bqnKLu*ndka`HtgQLcdc3_ch#y}vm}SQbC9B^-yysICpuCH z$`VJpRx?%6n|?s%Y5gvSp)FzPhSaKg+bHV%B!U4a;lWQy-ok73|0Mp3Ftp^*3no#| zWqXQLqAjA#Pf94w(ni@K^1mf(84d2(OtXv^n?m=lmheu;aPUVC^9B!A9DIVt-b(N@`S-(_}?X36z8Ue#DG}Qmv`|}piPB_ z-lKUcXzar1JdU6E*FWfT!i{j`M!MdjIW`v7F`4T>R=*57dts$q=csVip|HF zO~ft^;PByc>_*uvr>FKTox{=zmSn9&;?)TRZp5AAaXi850cmz&42DoIMFo}$z1%_7kJ zNyA+{pn5SO{n$Lr6gIlrQ@s1(-#QiJyN+M3>J$fB4tu_Zm)=kv(5W?IGM_2lz8kko_Srk!i zVzD?o&b*xOec!iE=^)(ho|)k&P@irfGM$Pw#2;z)Tx&xQ(5AXA`3gm*xTQ@pFa^v| zlru9iF3g6%!X3t4$BUo&lCL8RL`*Cb?;w6C5@o+YUj9j|hmz^(f$FreK zvoz|l3u@J2N^zhs-l3@o)?uBD>|*2xuG=o4P-<#x_L9m50INTzYx=zk(}nJ3F+xv% z=|=WI@J%uV|25{}`|i2L&B+J>dXX*LulWrlNK*B6$5fq;gb-IVyK84z} zj|2irpodxlNJs}+?Gk8_leL(SNZc1}&B8L`!89Cm0!I8%hfC8A_b6)dt*z0_%ZOFLTuaK~8K zCo}4@=x@?Qs8%1yPqeuMl}w}abv~LX_C?hv#5boQ?-)%(jupIl%R*Q$O`0vHMB&{k ze>j<8`)ou0+J`z2libZ$`kJT(Sv)%zb$>P=?V-v&50~f@TrE|iVr&Bc$1g>Z6c8$p z?i!rK9#X}LebX1|VWj4={6}`JMZ7MyeM0I*Ur&AB7Dia%uD?YL5`jx04#BI_c*$tD z9f2Y`wYv}}^)q5I9dVrLo3b53KUuBUSS@_o9(}l(;k7(mqjVp-=cK<=-s^6Dj6-QF z@y;2A1NkNuOy4+??5_zyJ|RexnJco;=;MamT?=3!>j{Z| zu_Zw%gsAWLinmAcvHrEXfz%tSK%E09*4_oIxW0prKh6)o{+y`U8tK7ed4**N*W%p4 z>H`jjnr199{FP17%`7nQc9Pnr5sjO5&-?HTL|!CM+3ofPK54ess0#M2p!qp|{|ua> zRo`6%u%{3L-j;GeO9NF;ZWK)aY2N-5M1`V;0FVn`Yf?(-32p^&qltdOsSN^dwpCGO ziyx1Jf}4EtYEUKnaWhBnlT${SPw zRlUv3YvxcBgd<`qnwwmcqtOGTTe9Bw<710WiX}MhJ&D#VLM{P2^|89)2)+CwI=}ml zLr#e)4@b1HHAV0eXMQZN>NaoHjJuo5kaWL^5Zh2VSUXORwufUE4*DQyJ2hF_Qx&F^ z>sgK$oQg&NCPyhWvw`5dl^&zGbkHsi3ty;c3qZ>d5AndS&0LgNG~YHdKB;4+AC>2J zfO3LXwiL&p?|{5eF5}>%6yf;K^I!UCHE?2RVP7mk#Eb(yvIPqpCNhA4u2;+WRN)nFm;ylSLh>OlK)_fdMcoG3W9odQiD*7b?0;jvUE%Ifz?xMI4Y!~`>!7!9S@I~Dfsvr>>7p`(x)+Tk~0mxvB| z_AJA(?=zWX@EwQL5DrTje@k}(5kPQJYlbwFw^)eWhIX&O#`IF}IB;jU$#LjJEbQ>E&PuCeK!`frF6mxO`Dz9OpQb7oaY)@an-L0Cx`1i>$ee)!`ys#F=gYr0v zVgF;>U%(ujCO^oQ`OxggK&*r<3}C^#?0Wqa%~4fVf&|DMqkB3V*r<1le8tguc}{By z-h*pkvo}8tqDLkHSdYi z!Gg1Fbt*-E1dn5zbNgzb7`i7oD6x5@jm5ja6o>hTi^W1yeHb3q84&HOFj(mfvu~kn z`+6pVOpyYO%YuxCYv9wu&hDYSr-CT_%H8m0=1N$$SD7}KU-HoQw_Z>rTNEH+?_ za~K*37vS_^tMSy3>#H%I#^28Snh+DVLri@21$$&?^5cW1qEZHPUQuP35WDb3acI6~ zJz{J^Mtf(fQ0_3qqWR_t|FIO+1)jgXZ87y9OHu5$-}L;Nd@#qIs`oaHuI(yxg3wf) zp#|9PL<(O+Tz1el1kEfd3_E#;iyVu^eE!p@C1ao>GC z{(kj~hQ?9p6QQ>%0}b@-3#ccB5OjKKGBR!V6RBk_zqO_3V67A%`ntBui$kUH>cIWk zKAVty5bQoS)f|0aU!jCn3|XURJ+hwFk?I})b)4_&-m0e_hIsudsNs<_NS0*>;qqI# zkEJjy41yksVT_9uu62#mdqPW%t$_qZ^27;C$5qU$6@)ahbe?OucI6Xr(nUtwcwv!X zivzX{fd;-o+@9qgQkj>7bAJMC9(5w$4|@w1!Sspi^=Yk1jnN~A@-Q;R2ULjql<2OS z2^-I!@+YLEs6L=hy`h1G)YWKRrY{6ulUEM%y@dp484(g6@Hn!Zny1{dx_yb;-R z?VZerJM1t%jV(X+rTaTxFqeW zbVGDLD>7X*Xt9P*VvrMs2t_r>=+dc%!&pR~9ewh3O(^?_3XDXwFf7+bO&kQkIb2nHEQTw zvZ0v;*yv?tJ8WA6;Z+b@7F7T=@0`HR@0JQsj<_3Es{?xUk!D2S^?=zq3K~l|f)FAC zxl_5h+shenr*Z0kh*|`LqrR*07>Zp;x%g|b=i^63<^`d3ocI(*chEx5V)!bN#=StY zZs4$5lje0!wPKjXVAT2i>^~5oT*-Rbqg2rk6iLj}Ab(Mzf%F*_O1JCqEaHU;nXh{@ zPi`>d!zxn5ee!;f+uY3NHEMF_xy_N3$h7uzQhrR$$7kV@IL&9)K%v}6Y zo9#-vSjH4M8=nlCP*{oOjwx zP7~G&G@fmha&Hc&=iy*)POPWl24^Oy*L>_4GaN7}Oc{U-LO_Sx#)<25N5IV;h#I$2 z6v@jo4JDRATXZ)ZL{*zwJ|NpkM3v}6lvn-X4$=p|=qM7$Bft?OO^1g^$Y)+t^)I)m zUtFf9Bv5Ct(Bw@>6%ST!O$I$lN#QGd<(Q-eB zIhUNEqN4xvRzk+YBuzE4VtdbW{3}rZ+pif%ch!lTgYO1ZG*eQ=?`wYi^!ZXltSrgp z4zt@if4d)<;P;9mP8v##{r|XyiZcvrjr~EGY2wsnq{e5@Ql3uf2p@E*&p$^eOCaX( zzuCdxezd^%1t?$nOiSn>#jQYJ-fHFp44Z@!lGhKXgMYoA{iW~lB8HSye(Hs8(iQDz z5bei+*^b}t`G2lg^aaT3)CH^UJ2xPFf-9Y$fC#DbMp-@Y-zD&q^t^9C&dZpA8d}mH z^4OC8nb)d+OThmY-a-V!UK)1Q9WL%Ulru5nK z$Z_j`9`v_7x;D|cY&Xt%N^=01*iaO4Vc`jf|A){ZH&E=cy<|NTDyxeUR2 zm30s$Xr+yBXlO}+o4Yw7$;AhmsONi8?yGJnd0h{*TgY0H2rKTb>*z zD~-LX=($PtPOCDGj~%BYeU9JFt7|e_*WXKZLaJ`Jxrb%nzEMb^^Tl=zl>N(QRGdcb zvLXFN`P{|hn;O{_KTkQ?=E6Iz-2s`!N$tcOcKM0Ev}1-!Q5yE;*N!9S1|<>Y(f_%R z&}XqOcS_a4!J~YLhGeTilE&9Y<6^l&9b_U`mNmITNMawXo~85uYJ~+4^xblC;nS=A zyXP-*0bq_28@9UgdHzRl6<5(F)+J^i2o6N(ITnoNlxe`t4dk&l%*ib0nuM<=815M~(K5t^rK9^m?FB=p^fX^SL3+UQnv5b6jUZm z$;C7-hFA&vagySHBmvni1~A~pccD4cQDH(UB(~$P;Mw+faH>6rhlkDgDdbAr(z&5V zc{kGXI495Fm8{WHMu{B2BX6zNm&0$J%nt-Acr8jl-P*r|TNVVVXRL)-b90Ut{V7XVRE!^0UeuS<39vTA1yhGXFm^ro)F0Q8)TN+N!l= z>5B=R;JB8qDQXX#MabuTob*#l#RO(p?$OCd7ErR(?-N&60>_yf7UfO|qRfe;qIG5? z{QME-k$1YJi(E=1Nxwol?tP?NeVk?K7E^N4POOmnpXJ5$B^MR)#wWzpEiDHma6%u0 zMTwv40w0q*gSV1lF5TgD7FvB}fEiOcQ8HvFqa%5H0$K_VYNvTZQCa*|l88FXooIV6 zi_b2c+?KVtJX!4UM>RW=PCH1}_{&eOVoTVf@9DK`6%udvRYMs=*&G&i-4Du;>i8Lg zH80g#y)q7`+^9HF+m)lrL^lbFTX{Sv-hlgQ&pKfU75?{G|FBxVq9i`$hVz|G=_d4Y zF7*wy*l|}X($|)55LNnq7;*1G6?ykDSnILVhC`YU=!zn&lp5eLlApn5{0>>CsvPn< z`UI#Fs4?;+5hUEra0l1w>Zkx((Bbt1czK0?Cee_Vv;y4`gL@6#fJasR9a%|BM{Nt^ zzwC`?$DK;@f^|TKOkd;eVmLrodSn%ZYH4XM`r-J2y8~6=oF}!bI=qlx7DET@aZnb? z=6on`bZ|x?U?5G4;06_Xb$yct5N_sVwS!EuF2mDe3LxwDvy6p4_Tp+RbT>`_mFk`q zRY?51D2kl{@6SlizEycz4p%&2_EWVATmB+Lm7mX=eSc{1)3LFUs{>tLC9Q@!vXD_e zPjI(jva;4i#=}kzbpk(bhAU$1`-vSFM60u4g0t7G(!>>EY)s2&a>bP7+=`qs{%3># zc1%KV#6PIJTj#tT-&UT#9L{<@wf|t3cF^(`DJ8(!6$y!tMuO25hm>+k%aCKTt~dAw zB=Teu`3)F^zC}9gSi+s4A?ID>^zeV)oMgFHr<_a1_4X*z@jzK-e_60$x`ChDO9!>T zSmtO6ysN`d;y|azMxU59x#Y&`y{6r9u4Vv^ zG&Yi!Y;)U#)m0&VbH|6XoHV$e$rJxnP*EcME*ppU>&F5$Ir5b-mp<#|TFHs}MyBw^n{!*#<-&6Mjn`p$YNcrPb?R?jAUv!=Wt zBKq?^mt<;h(BhZ;1xilv7EEdHnjE`0ZMo!Bi{}BG_YJaUN=NXVgO{W;N5S_UMG0NT z?>mIq>Hcs#`vL@4*j-PgZCGm!{+G146+%dQ^5kH}L`CgOzZ#WlTKA^gwC|!IC3PeEGC(1ohL7m zQf5|3^HDADVFl2v1#D7e%+cjd(-ot6s^uy_9IX@*-Vc5pU)G)d@i`1MG&G#|?nrIQ zNYMra?8bx-HL3~1{MNdiAdCqwiP=?|4krb4E=N`ohkD~o zU{G^NLbzei7_YAJsX&?0YHY;d&D>(c^uB#(zyN@Ui6s-3k|U>z#0d-?M*HtCD&$6f z&S4|STjb$cih(OzPwuyWQI__GC-8(I;GSP#breC~w)H$ANXaq_Q}`qv9bWLP^U;%6 zXQegAQvWpyQoc80RHM!cTd*M&=`Q-t>FX`{fLX%I3ftu}<-#duqE72Ip`^!)m+c*v zL{}M?&BWA$oE;+{KP^lePGX zHXb$Xl38KYV?Ij_gRkZmjgRXHogWsaHr#yYr{tIzuywTf-GuSvQd|46RxvL=pL%?J za{~Gy_<)2!l9&0@j3MKGKxC(yuk6 ztI4Ou7Ypr*k2^qkff=WPJ2YUazRlN(A+iEshe09Nw@XjastHzpS2w%gHiy)PdGP{PQAS@GXiZp5#rPjj%9|-&o?ZEfsQmdx?k;L z(rC~;yp-Y!HjaeD8qR6msI0muCkEj+Z-~SQ7u9O}^b;~;C2Q4>ugJ@C7w=1~Y5~Zx7(#T) zZ_STR{d$!3x8F2cKYC<@muVKdzmW#|in$wIW8OJv(K4X}BbYhUx5v$3*RoWgk$)av zdwSs;08|I61ysb^jOSdAmpJqet0}f~dgm5rX1)eog~joKj|no`7FkW_d>S&54EIaM ztmq9MBV^xx=co8M184K|x;<=TENpGOpA;}pXAKeh7p(T!%O=4>J zI1~nbFow;QF8M`GKoo^{k3T>_YWH=ok{rl5K23DjXBa9Ts4JIVou4qX9e5=s*6)fe zwRk|4DIG*=f3?yfPps$N?+`{8f&F=eAWs_iN7qkp%T-|}25qSu;^}V-^hNW;UOzJh z9`{8xJH2#rIF0)WJP1}j#vf)%osR5NW0C)n`85j;4Grb;n|E{-hRKXdyto-@Zg#Ub zHZggP}!wNHJV6-{O%MZnf3O$v%ADsB~q@BysritNqcj7?SnPvL^{XPTOX@(dM z!f7K?ePUy%dUX`5M3GzI%EktXgH7*C8aG8eGj%Wg-JD8N zY+<0W>5l^mS?1HFc1b1I(%IeV*sHUg%g!VoQOJT9y8%=Hu>sSIx*Du$lMMlpP?={; zV%q4T%W{MvxG~})_@Pn7TY+a{`Er+L(339>g6RU@ixAdCF&vm3eyZGmSxsX~=Pjo+DhSJ>U@g7y$it5R0MVR&#bM=NMWOeT@u*+i0UV@23;D^#Zh z!dqDLcy%;Ct*#fDZLVB2(6;7rVBC>`{Cs|XPFq8kQTRJUXNU7eAdQnK*OTI{ zsnxOXx8(@>0(D`)mF)+NBaMF_Eq+N!clYh;Jp5dY#5;h?9+S&gj?`c5>w8oafCkz= zPnZZ^O2fkY;NAs zL|!z%@M`PLIX*3EPRUFp&INZnWR%C$HCVZOKLA5Me_#M4r~?5V{&E|Nng9|?^fozL zy#k<;d5$LkiO&aFX-g40$heNql&T5E(*0zqSq$^vP#>fYA(<^gwiszdHYkIW(ji!CLP8n@9LH;-+iaT#ibdDG#4eH}xun?<9f=VTc-@!DvcKa- zgv#QJ-BAU*fITIRmt)98ojnC6ZuWYOU`&RKiwlM@88tOLT~+oY#DJ{HO&L3WD)HRz z=qi1Vt&V2tb=rqJ9Bf~&*=nq8&%C(Lue}NJmxDz*l$jj zC7YsD?76u1Y6@h`Pgl6>JZ?_OPb%*I_VQx@k}3|E<;59{lJu{Ye%*n=!Gr4vh1O5b zr5mh{L%*+7(SjLTB{yH~a9rxzc?H0NOuMkGCK4-DE46GDI-4}smALb*+)PL18GvJ^ z+C}Bv1+!Hc#Py>}?;AN5pxnKojmoqVB}SX!-Ho-glq$n8gm>^;P~ic2&k*M$-Pf4$ z>&cOKi&e7e^+n+J285A*Tq1U$zv4RB=}r~&K7YmLu!}hP^4(04f?ne|q%&^6`>9=F zyYE3rLnFnSpEZLwODIXl>y^{d+-l`x|HZc1&-3|$)7tUQ{Bgl26=<)$-Q5ZHd0hq0 zHP_m@@4LHG_pU&>rLGf_E zK0X!K8_rFVcZSouAQ5RkFxohQ`!Eikp7;9;gi=9~ z{>=A%hx~<_F{FODoZ0>)61@aabsI%=!l4xd`!V3fkmxk_h)83bP_YPPo8f*Y!0E@m zC_W%RLGGxKi{YE|5&anw=4iJR0A?ZFG>2iL`KX!tUL8a>(rcbHLx}4sjAzHWujx_P z7(mTh%Dk?537RuO`$P9(x{CuYz&46ol&P33mr8BJyP z&&0WAyE<|yU52SFK3Q&*y48tl2UFN&4Ktk+x&PdRGdm^d>1T~gN|JHd9f>=FkP#I^ z+?RAWWJW@HSUyRq5Sg*5>FsP@mD8{Z9>+1u-ASVq82onpwu&jc|6MJG1RKwz~y-q@( ze%C{#*x4gy@vaD&eIwfgO%@12vSqNcE~_XRBGrnD>{8{ut(HW7G@iYPCx1Zl?a`p& z4oIN6a}0Qj#ZK`@mH0AEms8D)F-_s~xYm}z>L$BT-=p+m?_G_--ThF5m8yG?D$T6p z>YYiqjVkNw87&0Jz(Z49`zWjxqz6lZ1U4z0U zEUJaHe)I}=J(e$YW^94q{eckrUJ7Tggyvth)sI?o|FANBN%u0EkUU!-aZ@=d)UP!q zpsC524xx@nfJj@2a<+dTeEKlE&k7#bu=Mty#?P=6tboqx36dQFve~xcNT8EwEvH8nq*`uF zFMMI!veK0#S1y!|JzZdguoEk@+Nh9_a8@uJKl~UT7es4FAys<^D)&>sLPNE~@u0st zSsu?AN@XuQxIdGWX@0lO<-Q%+NX^5eIkZ4IU#rg4a-CX+aK+`mchVRj=6BgiVi00;>{N>4GZXFp%(VQTvY(H-pqi{oD0Sh)_#dXc`7q6NLAg{%^Lck9Q?Bz#V2}el<$C9avD0$Fti35@so`T% zr^XP!d%BpOZH#rLrR*v-PA+n$gt%1NoYlSuG7qvwqcsEJ`k-YIowVN3Kk<25CTM9M zwY3`w)57Pi^lXMxKIQYZIR=P{Agl%Q$98@7**aJR=77-6Pp?nd%)2EtyL^+ePZoNK z1Qutw$ox|0qH=5FQYQl$;%K;tLoXGRqS$Pf7b`A#K^8K+-zW%U9*^BZ<%_;h zlg~cFHjc`gfcN7CFN9;Wwa#)e&R}t{5))s`_E#i6Vl3tx)iel~a8_q{0zL=ZGDdsk znw5v#R;p{-ugj2nqh1-!MIO0-zW4963Q`{1Ftta+m$skPgi07H3M@Ds&NfeLobq^`G*QdnG6f&` zDF7yiO_Wu(lir$Udd2lc5pK_y z3;PEp#7rb;Hky+!_u1Mn=kx@ZD}02o1}RZT(AQ|Dq@-{h6}H|aTX__RL_|0q{V?_O z`}CEUJXsr3^89B?`TG+yKr9IMox8~*7YB78JBn}InJ+D=S~DCI*cMwj4U6UYzm;FLI=`u(HzM}VS zPZPRBOyNqo3?RKs;ibrghmYaYRRE&$QF{O~eFsCmh~A|%yE*RV;|hq%TPq^;D}Fm~ z==|~;CN6{OQbHWSq;{1dtKPNpPSx?i-p^9OKbFQ@u8=|oi+GBN&%5u5Yi#zbxn&Gw zVtR`c%F_=tp1pJ^7#m{J%7QJ(a)Tg%n)3x?+1+O2m0=h3{u$V5fC{Prr>DF!+ zdD0$xj~Wd6YmEO@@dp7b0>%5xr0lrZIv#_l=T*!UZ*BA2g@D3U8FFnt`2<2qb(T6L z9h1KZdH0j9WDo}Nl3>lBItMnGv1zOtlfjrAuJyCBfDBKW9 zF@^k<(XFthyfqqA9+&DeIF3ip%ZMl1;Ik5m!P%#+qiBe?gp(-!zc#+ccz!iLb<_eF=8DrWAN zqLfkepn!yJhmh3VGa4bY)vR) z({_O;XW(bt9dyO!{m6H>6+XV7NXj`S5^N;0adaOnus=r0z1V1V60aaoIHOW)vVhNS z0bF$-9(C-(VE%NbD=N_piyZ6gqC8!=%l~-2TzT;M$s=^GLPs>@0DLvCH`R^8FXZOd z{EGg?IH@S2R1jo}EWXGZgDqgob6&?KE@OChUR&nt*Y8AkWnFFce@qm4uI7~@L*o)S zU|bN`oiwtr(|x$hf()-kBH&W2sAGFlM54%$S5r%DZAd53YYNIel;Mf`Pe)927Zpll z;nHTuLd|FIKA!F&h;Mq@YFOju2sBl)LatfOmDdrB*gc20@-DQt55c9>r`LFy8^rIW zeX*|j2`a#lj4)xZWDF~$H%c&61`&;+SZ^wrSPhSu*yIx$7#^ppwA?OWomLGX z%0?9p;x`jcZ$H}KrRSTm2cRpNicJd3XaMG(>940eZ`MvSH?9|ph_Sgb<=s7wFZ6ei zyjC06OTb^@&O(F7p(!&N+diPqRuYTyxT&*9=!?f2?fp z7_hSx1(j>EfqjGbeRFQ!N%RTaT@Ewt)SQ6u?Fb4O(m379fZlhw4+p`-eNz#*-qX8s zCFno`mh|(+_|xXW!=-ZGt21vC-$%{Aw>=1{3Vqo87I>T!d9S{8Uw{}EwVRy&ALKUy z9U4vlD|k4(f8zT%eZd?2TIs>k)RNHW5GSsC6-O6@a1LWJu-wBJfYOQaqtQPb%G_uT z3v!rv@cm1#v&56|nL%FnE!>@m?IqC^ro3-=zr1m5Jb38_doHRAJN4RQ?K9q@C!Naf zk*w|Ruq=B?)b?Q#5%jEPuvooJ(#NkC7zfS`Rqh@p$-`a*2t59Ld%e*-8kv%?ZVv)6 zw48qnKkz2@60usUyZTt6T!hSjbB3)wPRgc<4zJah>AgvDck_d32;SQ4C27K`W$f!7 zx40-6Sgei-$ib=U{;W^(5JuU;5WqUT<;4X&gc3Sw!Z4a&UrsTfYM*EAK1tUru%hEm zle9KFA+e#KB6h-Slzo0OoT)>Wl$5jr>3<8=&ut+;QD3BhSF5hUW9upr@bU+RaWnYzG8?cW1q(-QSzCt+N zoR8!n{IRq208Z-T9%36ZMJ zg}I0mk{C6N&aeMCr=bilR7x_6}w4PWm#h(5#{BgvqN!v~AZ6KzW<4;h0cJ1%h3 zhHCjtm`i2#?wtuQX~U*QiB@IisB{?jy5kQ9$I0HL%ee;QI)h$&1eDiBSPWX1Et-m4 za39tG9zSIBp?1;K0iY&Y*!zFd_a(Zg^R?Q4mkw2(DOD}Y^E&_Bp99Nby+U10ZfIao zEEP}b&ZI(Q(-xHjP$7qDsV@`#PXoN-qgdQ_>GnB;$j=#gBnIZ2NRJedAHZ#=N>YAH zfh07p`0hPrFqi%Py`z!qd^)L%&|P}g7B3;<(y(*EWE^1|r%vA<0tdHqq+l#eyDyEL z8Mt&*BmyQxN6jdfzH{Lq2`&`)l9!1j0Wy=3G^w$J%ME<8(j2ZGc;H`m;QH$#ko}DY zr|Grjo&L&u^Uj!U?)sfH>J_l7@ovO{65JjN0^otKb#8e=b;je`JU<}xMD63?$C=vXH-*?Dr>}O6FSyWg@))Mt<+NJd z=xTn`T50q1Sw`Xt;an0X3YEJ*OkXi!_}Ny{hGiikz)T&}=Ow6F@zn7^Nod=ExHS6& z!Hw=tTW#`PKlRH8SULZr`~+#B@wh%l8_#>jRzIujN{I#|Q>1?~5ydt#F&UzIbqAkFzRL8KuIw5QSd_#%MoSkR z{w{eu03KuixORLOCqYjW;~(+}cA~+!e-(kSX#h{Cud8TSj8%C#7a>dPB}>>Sst)FB zX&5Vvg1WVL0a0Ik62YE}wQE{ykm0n(>sbS>;mE-?P2+e-r&U3cOme8L&pU|ZQmD(k zRNP~vv9jS2G!DHD+>Q>r(RD%6_t*VRx)REdsUs|r75Vde-m3C8t+%`QTmn<%mmM?Z zTG6NNXODPcag17j6?SOpptC+*hFTIsX5j4wJbP{?t~De46h=T61Xr2O;2DY*d~8Zk zR#GzS2|=rQV+BDk!4Qw3Y-9x7oT-EGmFvid@Y;2XXPgeE#1AuF zx8NaAR^d(#r_CDQQj1gBKHquQ%gYy7D2E%>`M;ZIpUqtY1@!lh`bf@rcw!@XtQcO8 zYg6p^hM4_yOL2p-I}FR+coSEcNW$9-7$0Qz#tsR}ZVl+-@7{AL`9{ ztwy`j=`ReGTtSgK^`WOHGYI6(tRan!4>p@TG_TKg4)(wa94@-nm%#p3<)5|+X)4l&+6xcA#dtnQ@{K7bgkGH1~TgvGS2ZTsYkpfo_jL@0|-ko z6i=nlFbf`L>P&`9(*CjGk8+tqhTUxCvg(BWbr}$V0-$o`BJ?<}c04R8n#z}o*KQZ^ zyhAmR(ZuDnPB+59z&L=gyJK$w-s}gz);d${{O}c;w8+A)82Z00GTHQ~)GlO$POJJ# zuwqDU{?|q)OjQj51Js($%dL_06EUD~Gp+?$gRjC;)#B07%SZeUHvd~}(OFUUQ`c#5{H!x59<#S4Kkyhj|8RZl< zc}L;8SDIL@>1h6_7KgIp2h@7=<;0~1D@gztLW_Qa7aY#`{%_hN2)!_uD2S|NZ_k?L zyqr*VWp#gja!Q7ZxC|N6igB>?vGR)d7{&-4L=CDn8U1odIt`)Dou(;D7lGW(NeXBK z=W!0`8yg*kFnzb}oBggVL7Ga~(5Su1LQPS|31WErJgI%0&&Fx03SstuOi2UPc#Q90 z?1SL(I|!EgZVC{VwNnN-{A?3*BE;+5^stO~31c`&lipj|zJ4NE#@XGWMEWk$(F9$Y zkL}DYiIRdG6!&b?r7SFR?=?@9?lw-hQ48+mkM|ArTM-Y3JnR1Xz89SbriN zr&(1@EG)I+)o--@5xC^lNhUwco6vvMGE@BJYKCy2#)+y~i3VAi9z_#j4Eo^4cx9Z= za>1W;r93gV;C;U!r2rA*NTa*ZxgP?ueRcL1UmIJWy;)y~785y%TQ!#$Kk#-pAP^74 z{B;?88Y{g$pm>O-p4F=Fg=%`5Sr4n&+-+5uTlp6b_M9V226n}w3gGPb;oIc*hGSzi zRFiHrd_F$5#1r)5TZN#CBGhjDDPQFE51i(8B=<5s7$LyI= z-Iuhv|2B^nLKQA;v2^e~X^!LZrge(zYY;&3?PILv@dZIKSEkTa#AxI8dFN{%0t z>knI)f6O@9nLn4e#ijir~+!sK_}+v1z=o1mV}%N#IDI3Hq?m z`FKb708uG6cd!|B4x7`ViaP{4g%_L1f%)3{oDse=pU8&dfBN-frqkOyEIizy)we2@ zS>I1C$NPq_ZbL6w2VG@J z>kWl*jg&~{btYId4N??q>?La7<8e6Q8t>Zrh(}O7aAP(tuQPUCPdxXp|G5hwySjz+ z{IIN28Zi%27z>v~p9`Lzt7zWEYwox^q`Lt&;=VN@aK2r`*j)3x(ZS=g{opI${q*4C z%TxZ}y9`15eyR{KW)bsB3ravsBF=Fd!R*kePQ5-}KPy_vm1onLfYucx`Orkn7cVpv zl5IIaWbSP7QsXQKBkIkt{AXZ1hvg;@nEe5+fQ=#ht%Yi73`pcxRUy)V6+9ETkpuZW zp|5iFkkF4z)GxWqVqQh##4ZM)vrD3SvH&)sn-sI30FnQw2O*{=QO)fP_xTIGsqY zz4uBXX2dr9_qLO)Ew$S$YNAtAtuBR40|0m9Yms57W*C$Z;HN=^=9fyL{7FP-YrZY) zCanGB5LCLlXp*Ob#SytJX-r1y+zho`ZakY?%f*3kJoMzf4Os$<%ru71mn8H_l(NPN zRjUUDPyB)9_nSqy~(k8Hic~oK7$IFq`h6r)>EoNT3i_settDiLgtUw7d^vbzb!;|9qMz~0RJXNqQdp^fVQo3 z_5O%vkpq2PJT&Ysb+y#i3(d-UiAqVL89RU3FWLunEkXFt&Yklh;h7A{sbYshHM9YI zQLQIuf+a9Rn_Wd)Y_`&)vm=hhy&7Gp_al#I%dtf^q&V&l76ciOrGhVL* zx$b@UtWr~Hcbxif-_!;wAVDxzWgms%KAz1dkww_CKc5CHkwt_VYqJawN=8t~SeZA!%lMgfpX400DeUWL^7ct+rMB^;&{=;hW*IU<_QT2 zVvu5g5}miCYgOmXn#Y!BLTW0l)~XPuusq*|*!)}^g$$smQC~|tp0Xnbw$^l ziw$vkHSFWZn+pDfQ5GnJ!}+|84^KBaTn@N5+c2Ej&U%zEcW*;gk_o0Okm8|F%+~zG zr}$hR{X{~6sL;GFr@5=L2myAMX?;len7r2VST<$LW1N96Mjqh@MY$YP7)z=5}$h^SKLh=^w8*ND(%}> zVweq`ZjD`TPGscdy>AD#iroH`d3GII@e49|8RnfZ8QQLB` zwu1L~vRqz&)LwQBH)~#GN!oraRc1jzfGeK;gQ{1pcAXKfoB>mW6Fx^vzC2-6J=@0d zW8fod@F>f+*G{syjP`hB3~Eo2xpdL>ffUW4l-G4QtxugWA{EHhl`F&kGN+L&WBQ40 zaVx0EJ}o&NJOp8GScu9U<&(GyXu$<|E z=Kw{s*@TdAqmz5&6_LWrF`mf5Kz{I5qoLV!VmYH5l*sOo3{%XD4d?W!hMU`@b$E{I zYNY>s^caVd#0QySnPBr>d)ejp*7}b=K|XQ?_JvKeTWvNPkN$ZB^L=9xW)a zwB99vW|kd?u_vCchj^awXN$&?~-TZmacEw$`m~m76Rj+=n_)!57I7sb41T zY3_Q(#KLlLAlTe1k8g zA6)B$x_Ad|t<6I+o;&r^JQP}b6n-uA5C-i%c{D>F+j&~1QKBzXN9B-=mD2#e{@cO6b)kL0pl0!TejKq542&Z3(gd{5Yxy?ckTx|NW~ zbM08z8RWJTlh>Yo=G|AlyB;dFMrE0M@hY9Yk2{rkU;KD(@|&F4VsLNHoG9kj-}lhp zdS2|`^!05UMtT}y(_r?@pgP5=m>Sm2L6%>m+m6qDAj!!+j{mutV*$xi>EAehn|~_` zZI-p>f#4BN!>xotfp`vSgZ;=)Es074T=BsyWf@iXJldKKRC8enO{Ocn#dZd%|DBBe z_e!sZp#LDF=uggPWs$F#o6fj#U?F9q_f>E-~)j1R1}Vc;yOcLg9y2fw>kP#qH+R90eu&vc=A%QB}UlG1jNV zQ%~lqN?i$AD;oN+ryxeFeMD2u6qIHDUZ&IXBFe1-e(vj=4B|No=y5`M)#nURUngZ7 zThu$#Gc{of>&@#8eT)Lt-i;r?Qj!Y&kJEdu9HEPj7l*Se;dvHc5T`<#t)Vx3D2&L8 zL;N*VlDWJS5-|Dw#NZi7~%9X}8qR&(U zkA(0Ci({LUaRSDIVu|G{1fg!{yH=JJPOf%q4mMd_-1T*IUF2EoAphLmg3tFFovv_ z7Fp;`z&GiFZ&fNV{@~9PSs^IjIlg};3wHr_`a&FzxcRS-md)my2U~WYk4J`P&xiEQ z3`TfK5u;xG*CXPB*tSJ&Fsvlc))9)teq{wcOKq$}LXCF2AewYqb8fS-Z*gaed<@^o zOM#&yD)DZ6-(lxd7qmgM1vUTsxh5Xxrmr0{J88E)*RHjuDOY%MJ4ouCBNm`6#X=Jp zWVV~SAWfxjq9PxcezYK-52P~$a}@bznvEt5bBIePRu4BgFWg;@j8Lc-s>N7D3Yzr{ zB~u1_kf}${(5q?dmOE7ohM$C>(b>$K)He$VnBzR$6ym>L1060_O*xjrP;VlMiJw!4 zyTaE|@L=0H!v+ z@rtLhaL&dvZ2$D2j*T~ODylM(uPX7Q2g{r*iofQg$90`JsJVSfYy}ZEJ8hwt;LLEX zF@Ti|93dJ9F21)6Sb58F5~5RR&RW$=LoY6Ed=0#lTX`Aq*s)8o2i!ZrucdDeB74p5 za4Q4u>d6kgyIV(x%cppWLH4&t8UVX)DE-c;qYIywPh*NrDuk(ru%Bi|>lA*=YX7Pi zaX{dER6&(L3jJ7@^#*n{(KviF@meo`E4t@cFL%wPA$-MtTUAfmhoQKZf`hwQ#4j{`os+*$=64z_&Dzk$R^K0$m?|w(N+b=nuu_B|vz5Dy%Nr5% zYztur70t?2r#c|vk_5x9#?!|E8xNNWsl(qe?r#6S zAAWHkv(HzSfT0Fl2v zQ9KA;o>sa8oBa~R=tP(B{^34FO(qgVj_fc|8uN}E40}llzc%*m@ob*UURD9*KX>px zS#Hu~-`o(~-DNk@vAqKI_)%;!u&aKHUnDgKju7P`xNbj>pdY3mc^tY-d=#ARpMB6pog_&k*oSpvqQt+mh8PNqg1M+)!P*GhCbR)2k|k8bwKuEMZ>n1V-dS!2TLe zd;#Wjcjk5PJqr>s{t7D1m5m=5PBj~xylJX)3}@PG4my^7hvGSL!}(~yvx4W~trxjN z%ul^Dr_Vh0&JxLUL{oq_3T9E#RX^7WZuZT=XzPs3{JUHQ+{SDYJoS=aAqpSD11*Dy zH2M`NPE0;i^nt@>n_ob~SFMlk z73QkKrQe2-H2n1Oue{TEw)s?$H#o&0kvogQD`HU7+cN ze=p^Fi79LbQ4If$%?+8`t*%4IVw#?q%`CrxRbM}N`Bk=QB&Yq4EU-Ff+`F&+tyt=r z@-3U&RyztDM~A?k4ar2&*t_2R-y1U)Zs*U9Nd&vDFD+XtHi1}=M_u+g_P*mMOX2(0 z@^{e&+0ui`>m1Gtbui&;MdmFYlf>yp9**5awL`NjYW#;}4@uiRe6M)Y(_aeMI4xu` zZTI@iH#q1L<+RdG^k_-FZoclauZ#S)z44l2W+D&e{I&ZJQNdWU`58D8RrijsF#89t z_DUoq0Q(a4%7$Vj4}l_+ScQu)ulHpf8SW$S2PGEp^Uao#g7HP#uLQ#UmwZ{3D1k4W zF?zaMmu`JtC+H7@{7mOPq(%$5&({PYH@#cyBjka!wP0w!^`gq#h$yuSz0&A#B`|6KE_$=_>Z zPq*;!BT=xj1FT=!QwAMp&It0V3m0*^n;UW^Z2Dj*{d=`D7g66zAwiGXOsY#)v_%Vh zArE0E>m#y*E4aZ}{=_(1gQ+x6Ut36pf?@_AcCO{yJ2G3wbZYsa7k=qu(5U8!y5}mw zdNh$4Rc=+kLV`^-t&{To;e4gxEVfYlx!bb-u+G)NlUNKBD)hF^uWN#lBOf7|b3rgY z)7}~-%8J`oAWFBZdz(WY;+flYv8NxQ+zH`@)*ayi603Amm}uwr^EK>xf=Dw@8vw9W zBSj$6Ydhy+Byn%=*}WEC=6}0Y2}!a&U#+hnVP|6h!?nS?`I@sw?ec$r%-f5qK;}4sCnKee&u{g%|M9`&5t9S28w! z6`?ktA{`WwKKoUkgJvCclD~L8dhPc`Q$t+&DlKa-v_wTzh!(l1Ww}Iy4V-!^rwlBRRnbn z`vqAxXv5Lt-5E>Ob{T22^Jf|NMrKA$=05^SbQ^5d{a3_Zqp&fkSAey9Q(9-f4o8BQ z*A5i@W=*1{%k&%6WY{mZnRK^ShIJqEcs&m4s9&tu4uoPeIVsGpbC(}eK$XgNn2{3d z2>lAlMLw;5{8i5K@0kO?_5*2hAyHWNzqH+W>(@&&z;hQyZW4$uW#%N-5q4+l!2=T! zFtY3L`t*{UF4W}(f-qihSZ?@fB97EV`D1$aIIs+1az!)PSuN>lyCW=sxBNK8`s4da z>ILKt6Ow!q=PXYxUrN^G*ZnynK^%&mHaE`c+v&WY*44o5fZ6KlqEB+*ORg^D#?-@U z)U!n+n#wsmH_3bRHIqvoJ|tx6>l~M~bC=^@LbX{i4erZk9 z$D4&y+ZvR1Os#cFne~ORIbEmM5-MSUfJ9T9b7*gTo?OfIuBbv<@)en8&uJSyIMsh@ z89eD#YSh@Tkh5D1N58*3p7oTc+Zi>Cs$XG_dUiYHk}JsLFN2d~BuRJe0jB!HxdvC8 zGYnO+x1=zjau?C`F*O^sqMmR2`VJ??>Ik9~ugznrf1qYPR+%2a4*v!k!Bhb`#}Cvj za`J0Tmo;1pm1g4`j>Joml(2NWdV5}zM#;qH6FN}IN2Ko;5%YSey+$T5_Q@P9oVx_Y z_SDd8^!D|99lkTEMsWLWCjDRms}IY_&o6c!1esnYb`)SDzGUD_Y1O2O;dD;^?X#oQ z5TGtcO)M6hupADoK2=5$NeRKz?m>5}8UZI2D8F)sC7|Vsklw&rbiQ!zaoW2a;N~h} z^DoWcJr2vw&x?GTs_7X+D2@&zgm32B_^ve?u55ny4Tdu!WYgvP^1Pb!(oMin`^(U* zfwWF`eZa|=A^V>yiQTuBa|H)=mb09oH}UoDrvzVY1$1ZYK6v8`Ii;;`5v`AMpZk`nuSL{qv`X$R z84Q*-87w!NZ}cco8;-{0GMLvN!6XQId$hlG((oFH;jCP8wX|#X$@2OjhN%&-52x_+ zadzQy8#8l%IF+XMbi4hnm0UtGM$+c(6)DU*!jYcmo z?N8^;hnZeg9;)OZKgG*r85@;twtN~6d}~&y0wJGLJw^qr zQl<8jB!M@3OQ-JjJ0<+Eyl(#5TnaJENBuOLsYQ%jZQO3{@rhOZBhZQ|I3y=wlBAZ;Ai>Nn)5pDTLe) zDb#H22Qzr=bUvyVsEO(bj!)?xTqkPWKRgh?>dhT%4In&K?0a9e_k7*GgUQ>W`fb|4 z0)A=*Y0o2R{6L}2*#Wqq*Dvq&%U9CJw6HZr{{`4y!1P}o3for4fG8&(W^*f4n}vzk zdeDszHnqYS?vgsn67^{khCIVxdxTSf(08BHo)q2Zv|4PKSotD%zon10sm(jHMjDn`lZuKS8ze}88)(11&*s3KkYIg+Ec3a#g zG8@DR642Lrjtcql#PiBg(`h&Tcq%lT8?i6PzxI#tTx}DbZ+-rjHUU_wcixKf+9Qr- zGwq=b?mQO&{VFT5E=?O-607I%RcUE*56hR2OBb(td9*vW)=7{663Nu%^UFs&JLyU! z7oJ2P=8x0JWQfUzN0KzO={S^ZWnvq2&p!VeBdA+*qmz;hPolf%b`aO>c`f%Ir7|!H z0i9Wm5$ZAM)9T(rjZc@<>cM=a)5RR(qqFeWrvUYgF5oM@PfeIN4zog+0HM6+vP7BY zjLDw;2X`mSUc)Q>d&>=tbMD7?xnFPN_g5MpATO5Q3XuOcWcz6%AZ{?IZY>yTE0KJk z`&NP!fsL==)^sj9Dh-AaAm7AB5p5kD#M9o(#y~c2GpWRqwr#ww^e#NUlk~cVM&R2) z7^KB`!`NpxFEnnAxf-(%;mtd~W2qM8en3kiCp9cLL7$G4q1m;fl z{pODqCG4~n|D|4N&#>z>C4kiy0_+hM-pSk##;+&&*CV2VUJYL-m!dsSOv3o2+CGh} zy#za{ZWXfoN2wY$q}udJ_+&ZJPP2`T5V5vq;3**3UFr>w`=mLuN7j^|r=rXIRsm0V zpYe=ey9B+<=6PiE8mTJCF&<~kixZY6D?pzscTPNj9%kMix>r=Zb&D(X)(y`I^1GJ% z{ZcY1BJWVlM`GstW9#kinPc-=WnQffOG&Q|C0F_%BRn02A0xN$bNnl0_8X&!A|sKp z=8Wutn}S|7g5Z~ zWg@n`UU^+TakPmmkHZkjysogL)DCSz0^{^M;UUw;Y`lGLIKI!ky`0G5RqbE5oMm*B zga}l7NdZw32=dQPHS;gWAokH6aJg#z>sK*Sf4vVt?8TZ4F0)?TBt4exLX&=lHAEf( z&Uzw;>lQiP7jW@NO#g*dX0P^(Z&)|{m+END-MQ1)9!x}En%<+*Q{st)sZ99e%-^v3 zyBwJ)7{GW`;#F_fo%Qg}rq`fSL+bPCmv=1~*(Z1(deE&-NU^}Rpix4B5M1F8fbJoqfluoegdB+KnSiNFl=P_ZS~UPXXXkkRJ{?wc`b zvZxoZ??a>^t>UmXYn=bWX(Aoy(yZi&wMzwFGQ3__{tpe$QA$3aB=9IPb`810cyZq! zv~xZMnCuUK7$ig0{(@j56B&Rc5KqE zE<)C8)E11rs{cjS0}}K+sHc45874!+pJaOwTlltCXCQhtgr4iTbpfTf(h@vMUd<=u zLGq20uSNjIc0)HhRy(4)8}XYiV!sHBqCF^@zf-6njk@jU`9qcd3wqlijL=_N`x2uJ zmJF+H1Dqw7fDj3YI)NqI&JbX5S`X{nb*9*14*k)y@98UhbJ^XCbTO^6vcl2d*g};* z+uC9=F&xRhm**EDF1l3wxhQ)yX0$6)J@>m%c}FV`q!dFP-s7+*Muk&~3%|+! zE0F-O{S?QaYy(=Lh#g(@>(y;9f-stE9L-3&p%bZ|6`y@zP|?}~XV`j(v>Sc)OKVU5 z7UEG71^z>a{ZAHmN$yo*kx;|%jyD=_WZQomdUWUivV^};1_CCJ$6n|G&uY^G_~c#b z?ZgQrpRRFeCoa?)J|dM{h%RkE)NB}yo8`oexU#p;5`*2~begX{5a5{&+!`dW-5#+J zh;3PD(R9nu;9ZjDLi4=fCNMz(6Rx~8q+Kb%$=2H6JqG^}g>wrU zQ~WnagEv$&=6vkc@@c8Oy5YAkQ;sgO0CG{!xb>aT{PC_7i{GFrq?(f z8$)gLM+~B2cuykmJSq3*D$lPr*-s~=lg^{iV;t}2hKsGsy|ydGAL(a_c*#>EGh_5E zNBY~@aX0bY?v$Sg-XxavQK@@g7)g*85++l=7K^ddYjb2ZkU0u09k>;LAv}H&IvS=LrD%L;nYpRcL zu>r;Y3}fPucBNc%&~P^AOe^72OrY>c{006y*-8x-i9#hK%9DW@h{kD-G;y z1SMT!Gpcl8$7nF?(i$q_Q{EzBeW{76vKxGT>v0F_PovT;3Iv?Ma0AvNzUZUuCk@47 zEuJLRGp6q9qp!8@x9#>$~3cO-C^lW8Xtm$%ru1!g~mNhdd#HwIz^NSj(P zW*v^5<{yKMkvkrlER}2x?W#JRv_W`?Io!`SRdTef&t(#Y#KU@8ylyM{i?Om5$E}F| z^={)TZSJ!p#ZG%_7c~p~zE5dHbG5o7>EX$57UtRWw{FTQMHA6=Yl2Y^6CXW+h(*eIUsh7A@kN)#P*@9}X>7>9}!9IsnNl+`q8n-*n(qN`$*J0`uN<947gl zah>f-!-tYzZybZdl5@|7Yx^fz++rD1kQW!a*hmLrx`k!O*SY%&pYPjn8hc0IqsHa7 zJ_F1L)T7^)wwMkmr3)}OL%e3vYHer70oQK)5{_rfOV%lBo*A|aH?`KE#OnuC9%mW> z=0EG^&H1^OOeSegu6*u}7?RoCv>F*Bv^qb+{+9pR*?m zTc#W=H+SNsZf;f|7v*~|$VH^NFP+V^)ARwITcZdEG$tNC5S#t_d~~)G+D#2VeR8@| zcj$9#T;s*>`P23mn4TjWuS37AruuQJL8`)H)Y?g7YbaeQC25A%S5-lUFXsZj%(}t zHH7lZL+*uC=y|G@SQDzjhG|{y;zYzd&y(4oR8FVsaH<(hp3DVWxj_PtD%|IdTU?5( zm`E~+4=;EG|J2Ve?w6T3pVTLxnv80O9-U%V@X&mH?r#aT8!eJANO(k%u;?xJP!AR< z`D^WBIOr*0D_`BG-xX;)*G;2IJZU!$=#gH2L9;h+L+pw#r@ZSwz{h;}sKN5Aa3UIW zSXVaZ_*H@QSSJ|uAWLkN!y%eC7&zKX+H5DFN#faozSL%{{9IBYjrm;{+Ri2UbcE06 zqRic3@cBj^Lde&ReYZ+ymN~qrpUJgTQz{ria96TY85BI}(CTF)RDZU~O~T=Rj$1Q4 zjE*?s3G{?axigIH+k1hiORIdP4UQ%>Kdz{+=n`+7&z*!TkT#a}f%KJ8S>?>mzxhscs;MG&DVQNAWN27t_=k)vkuLhDBf zMi6y4{tR~qYcWWBNnO5TvzAm39TB3gBybrM$EeyKf-Wxfi~8Aa#n!E+ala)vJaIh8 zwNM|uV5B)bPMn4k&u+0vv?W{{p~X=Q-R&atTu373g#+Gd$4 z&?R30Z^j6&b7HSrt<;!(L%YWKG4q}A)MrOA^gEF;f-6y=Y9fWX>_(PGrJKHJ-TI0z zb9|N#yhAQJJs5g(pMHhK8x%iSo5&K$E{2B3=EI(dUk(xggpBk?6sYoRq924Jh&oP1 zL8t*Ivzglihw3_>jZ|~IfjO|^qhDnHla7@p`-BN9z89&_-A+GJ5ST5`PrGoZVwzUw z0ymM5j#qPp;26r^b;f4MIzTbsFV;qt$htM#PxBE$7~5pDy_ zN27HcTG+d=^~mU}JzEM9v&E#zyOQ~j?8%<(Zw9NR6zRapxr*sMdWp391`7hSk~oF9 z;X2nT_kQqio80mgh_b{~fwe+x>9A*KK^Q%oLR_~Qi-$mAT5L|U zF`eZ*1+&Ag(rTT_6sxE(FbFJp>c7GM|CR()F~TJ-i@2B#JWQs4y}zX3>5Q(BFyv% zD76ruOn}n%LAJWWxoGpWLr&AmIhLVAR*I1$~38yeL(?Q^r`f{YJ-LC1oP zytE3*5KOG@PQR}5iD(J&(s7UPjFr}uy&o;yhf(TJZ@F+{EV4^yn`~&mLG;^;Km`>Q z50ROVKM&XolnYXF@Q7YjXlS(-#oSriyc}LbS{@ zG-{2pKDW>!_*}1#4udmvGLbnq#Lz+R@WJKaVmJ1kDiC&BO$B{T;%uMu9^cZN>`x-%;Ji{=32EFEpIf-14ZQvK5lh` zGLtZ4{*IV`V-s5RwnlWOJJ_q^dF(1SFcLr|Xlo)(a-6MnN6uxzbCGPVmJ{$WLNR}s z>sht`=?z9ZVpBs=I#R`}q~dcDVf-mMpW7!JRq$B(&uNFm815xnG{4npaFfKMOK{n* z6ivoO1Pv#{|=9`v2_3W7N;{NH$ph*l6_jk$6 zwvy+X*LGn|LI^eiTW=BK8yug!>V(iTD4=BMERVG4%gB6WH?}v(qhg86&vtJn*sH!G zVKHtqqxDxYsFfTUY`}gggTHcDDD#9I0;dyofI?)gPdvR<^#u!u6Nk%dJ}@W?qH)(l`^B(WO6Cv6$ z@o5C0%7)0B9|auRCV3?;uyN^I>LQt|Y!stsx(LwMp^5dRWel0(f3A&;F45Vlx z9RIHLLU+Jnv4UJL81B4<)x0m~P23MVIp+ z9)n~#3}a|Vm*+4v60~K^aiwo|z2_gqpwODYzF^J|#XU*`S{Sm+*@P^H>gE`*taXbc zjE-pjT%=Dmj>0M7sRi*e7Mt)Vvn6+&OJ7eT85!-6luL=Nwn>1)j$Ore-M*lk%=79_ zZPv%Ubf2;14JQMqBFJ-~8t+C1t^_x>hVMUfWSGo7j!=i!hC6NbvkYf?jT;0Fjl;gQ z97HTd!c@JAC5tu!R`0( z{C=0akaoR|-fpYBT4(@}x})!~%wt=NPC=x2*re?ljyQv~q2J$9XK_}Zjc{+51^Gxh^qdhA(u zSEP9aHFl78DHaF<`v$J!F`G|leB0d55_3C~)#>xlok`l}>NQz^?+`7g8xf}L$SW5kE#;Y5^%7m zRu*v?->&g!Cdd zd6C`*XiYGJ>1YNweH(}7d*HPmo5T8qYnczrTgPWxaw*x?0ry3^znlZKTm`kK_o~f% zYkU>()+(9yQix}x1PEV`V-hvL_PHeW=y*_Eoo^p}<6c~LPSVtOa4enOn{nYLH|7k~ zg?VUCrc?Fk&k(H4QOcYF(UPyJ!roS4xQwTz{b~J8WM3R}fqMPmF(B?IM*%>xP-!;G zWP#*!g4?>FrKy=E0W{hOVqP-|qA(X|0CM(JR^W5%assowAMojjYa7Jd15ZN4w9rC< z%57tVV=Va5F?xs_b>wuZX#Td@e2&?>Eo65aj_9zAq$x^V;-}l<@s&lUiV}2{&@ptM&~LRYeKnFD1iFo zy-$pIAng=+GCL!llIq%hG?k4T{yVptrSrCI3vM9<=^HFsHF=%tW56W=CpXjNPhj+H zO0CZaK9?gZ>7ci$)L=)ug_GLdHy_IP`NgN53#x17zVk$eY7V-U%0ltZlCWk`2rS!W zu@9yGG3D`fV<7cH&^7)OqyHG}2Q2chH^lA7xDo)s##?N;yf4H4$B(%544ZuafJ7Mx zzrLoe%&0xvE`5X~4#Y8UYi7)?WY5^PQhd6k4nh0bs=>krBU?lA9s&7m#P;1Q!@D{YIoE z;_xns$DF<@-Ko$X(Z`V}+(cbyn-d-Ik7b^E?H%3AW3`UNCR$NZqkxLs3_)j|nvcx1 z0^A2F!JZKVCymfn@$cKweX+=;)XQVcmsbj8B~7oEzkY0)n^BbxW#Ya1oo@cyQ+I>_ zz$Ujo9AR)eop8OrDY-?-l_yteQ64hb#$>m-Nhhnt5QzF6TON6q6_A>O2*|6@vw(eH zr><@b2Z3AMAP$z833fzm@-O%`*|%F}?Ef)5^rz|!5OT)3MUiCKd{b6&RON<>*02Mb zR8Ne=v=+kSf8s0AvbNcSNG}gfBmjD|KUGnKLA5zo^6SKoQHZ9eHfUy8IDaMZ{?5Yz z2w-+6L(E5=>qit3P*{fXfI!R>JF`&zVOURu84fkg*` z=>RqJ0!_Ci2JTXcMOaywxh6Z({hwe3N|6%4BtM)}(4@H1T!{UP3cwbgHuD80HFR2T5lkgI6-Q z$z4>vNfDCdba`EK8Gl*{4BnTUEx$QOTj8ia2LMT;Ax~nc!yE-;kXv(EkGByno0nL3 z%pJSTkDa!$8ZgDE`#*xFmxyM=QSwd}vTbmH?*&lgWnyDStm}k+y}|w^X}G#1E6Sx9 z(b?>Lj1KAq$^-)0aWu+ql7en+ZzA9P5hG3zeQoO8`vuR`E!3sC6n+XFb8n| z%YEbTQvUa6G%@8tVU9I)EH>_!#etrceL#^n~EQgznyr z?wS9V=x zbalGJaCQF|>0+p$!}kk$KzROy>VHi{R~U44{{Q0t|Hc0wwEO?H^XqhAHfZEyP+z}| zNq;^>;+rZ%r`#%V8y&EWrG_t#-0u(-50XpdZXEnM?b~xXHtW>{?FZI>&f*sL?CPrv zz3FUyB>3#p2F<|kWd3qRAuq4f<`0*{8S&RX91>SY%Ro-?5}OGVVj6Ww?!F~4z`O2=eQN?<|?vsZdzB^T-z784GypEQ83gwZG{#+ zIfom@LXQ>of5D{5g;vt!T8&fqxL&OfmRbzf94_WsTz*u^b)wQGTKlwLV|O-P#4EZF zJa|w_U@{~>zVy}%#buz!6wbt`(w7K?5(H6}3$@Xk{g2*SPmjRZ3s8P!9HX`#1XLUg z#jg~qqmFIx=P7c-N?`r33*}oyn1I_gZnex+h%*Xljdh}EJIC7 zwKkZk6ttV+;O+Uy4z#qw>23%gyXYEKz?QVM=E?hdTZ^~2#MNI9|G%#(Tg(YpvZp?JoQ4WFT+=D2B5Q%IeK9>JfjdY8Q=#zKKK z+`1NIqeM;zQFf;IQ>hX3VJ0nos3?U zqI#jcMe0CG>l4=wwmsv{PWQzeC90gyrI^RnZQ>TR@cB%lkn5fK1c=UA+`6_@kk@A& zd;n?gH3;HgPkyMisiiCRP{qEr?Cf#0qoD7LXV2g4ix&L;vGt7KzLd$d*Lzzt$n)|n zAW>&c3@#T<=&mT;RJ1>xKhdyNxe?)6LfkG;5A`2@p^cN$QA6C$qh(SzPu{gjlOn}x zYhZd}HLG_sK9R3)kVS~}1ABref)=^;c|!4-NI*|UO4%d|;WIXf6q`v!!spH>gP}-7 zTIb>%rgly3l6miJTQ~2ifJQgvtOu1lCRH8zRA(8UAbD^n!kk7E1 zf8UZOq>Y5jygBPO{=J)eG{(>v7B}6{m)$>QwOfh9fxg-E62SmpIaIkeR zd8p$A?dT$EmC6Y=m$X2TIrrEBEp`m%J;RfxPzaEMU5z2C^aCPMCn=X6ejwxhi9FW@ zA|Wq{8iy6*f%EO*4Yy9g<~tqDdQm|yYA-$&AKi!GFoN@fg5!I8B}$6=@K=g8$8*-Q ztMgdI_jaep(9D%2J9UfYjB<@zdgl)(G;+klfp{j$%m@NwNdo@}fNg zM#p>sZHs@VNnfIk;SQGZ5H>&kuET0k%X49CJ=@AnhnIUX-)1o6rGw5n{WN>14;z+;+(yBe7xrC zGzr;eN4oh~A6C{#FxOFI=d{om--3D20lh+79%?*Cg}rP#3Zq`_7HU_%N`1fI(4QT? z)$DWJV22KwZt9Muc3~C|x^UW?vR0kljmO=}#m#VD6a_bI9NiCd3iy$IOXGF!DVCZ) z-RKSPMH5KUVAKaBvzt5Ze>$_M%{aS_vyS0)ws5bMv#hmW7Jf!>6Ao|%9a*DX(*K%) z%P3N_pbEu}XH2e4`uQ4l##yicW^us={1W2r;O$2Xyw-P>pRm`(O{E02Pcl&KFYTJ& zdfMIWAO@YR`Y)6E;ic-#quiZ%w;m(Af#1k)5|1yY8H%YWSuhEEtCcdxW$Ti>ZH#{C9c2r#*Em}$h*=kqHW(d^7 zLD1K1p-C~iyvw3tb4ygyINZD!stGa+|K^R_o%`_baP)}$H&ItkMIc&4AQeeFP1vx}X zM0CQ5XCW0@yP7+gJclf=}8UtnovekX~R2Dw?$^_$-ImdH89cnntzG~C!fh5J*8J39 zBAH#TNl1eB`kTE$l<{E~_}Su(|k|^3WLJp~UHZfNuSK@Wx5#imRO3@-LShLj)c4d<1o&-9_;Cbc-iPB`GOu{k)I|l!9$y=;N{J6exaO%MJU|0e z7*s#G{|;9Fit(US{z)WD)9dpZhgoW!$rXcpF6~tkV~6mK4@5W;oy`?ZktfeiO2s#7Esw1fmkeS0D2ZJ&*8ii+D% zTVDiqhlkSOeoeE%gHP8Pf<1$;49F>iP|iF*)!KdMW4>9Vsg*hQIVOy@I2l8yZhE6T zw^)2gt|otP5;T;-u5?s?&kUU9bZPP0s=)7LAes|3oE8w65&!Wm6vO)-H3|_^I58sS zuggoK`FiL4sR=JBb$ZPhXjjDh@SWzi2+MX1`Od=qr#sZ-JWL^Cs+)}N zc<OP#5zg7|i;rO9~0h^^CPeJUu#A~1WC(Nmz zHNYqs-~7c?U_^G}{;?~@U{~i&i_N~QUvX$ih2U_JqJh8TX867@Q@`^V_N@F>yTbYM z6fgD%A-S9Zc|U*hFs54b!mvt>JbpX+%yWPrG+~NX6oj&u$_m&__Jfb(&Z-6q*uGrJ zwE^Fr|UbL+Df(R2LPo#a*wqu-Yl`yO5rnd%|I^%J-l9mj^37sJ)29h z2Gt&4E*UiHcwt{5RYBE3<8Xo*RNNTLNgR0080e9Uo{P@NQI5>`YQL0|B?Ot207M9V zJMt*^cfLguXH?>+FU`on3m++s=Xz?Zhzxx8Rt#`(u0rYvg3Q|MFYntE4SDM`oVErE z8Y`zm-beo8gd&t&>h7aGE}-L;LJ|p+ zZ??c$^Sm*jLYWOoChH9FyB_*KiH#5`n7N=OotK%b#-*kn5?ZLpYbKDci4&D}`0sB8 z#LK3k*JV72HG0L09*C~oq4M;neRw#|8{X^R*bHnhF;W#KE#-IRMNB}a)`T{d&-Lk) z^7kNWe*;H>VY_9WFz-AA>J> zKd(M=T<=2(@C$_c=7bIPfRn0BUu~-%F19*>o6FJD!KOg6yV1{YUa^Z7Ny1aM#3!ny)Qu(k{GQR)0#O>a=P+3LRPYAPedZ*_(~1^V+tZ%HF^B~6=@XJ z9dfbg5078bCk8UU#X27`=1cXeb=(dic48RKB*rzqV#IU+DlGSrf3SUs(yM*r*%odG z8Q9ZroYV%3fQI@79}aN}^a@PI(AZLK3BSK=V)fUaq>jA#B5lPk*O^xYML35?B_U?_ ziL?gtIzr<^!@Q=0qv`xCGjD$6P@B9>fJa13Ic_~BiR~jGciW$Jh$3h)n|DhKiI7%% zT3gMEp?3W!%1i_xb@-N{RmnMYw53?uuQ{2I7m0C^`OdDP9v zmCNlJHam*bBkaO+L(7pS$N8Z4#BOzoN{*wkYJ`9wL{E^GD4&E2LBaE<>zmg5qlGQ? z*75_mrrx5pC-nDPHA5&9*`Atya|q;!UxpnR4Mz|>Yl;QmQ-kNZqKTiiD%8kov;&l} z;zf}cYL7+0ViR4X`O=ZKo~g$KEuNO68Jyx`VB49^53Os_R`+QhXzdx+D>J2UWj0QU z!v@|G@mZyiMJ864T8?s>;FPS%%-*cMKeO5hmY>VEixqH3;(kDZDO6ap-!EKNQJ=__ zqY2U1#Jom*oZD=$o;ne|RS>^+9F635px0Gz6AdA4%7TC%)hh>+QDi*#`V)E@ zkxV8(EaM+me##zh(zJ~LAeI6|c~b6&z3}<0KBHr~6tqLu8A{tmrR-hoiyz zEkOl9`ETTY?#&1_>8Q5%TZ}ZDMbNtz-^0GycFh_8q-jq2775dk6%9jUwmw^9jBUsJ zJUvLxp4Am0;0=dYPW!Rivh-2jfl^1HQ@CuysaC`QV;sQdTqNxhg z%>@>E4*-Zhi1`#dZ2Rs6xtb_~?ANZ1GeecnFXpiswM1Id@3DV|8Q1dJB)>C{D{XZF zvxj7I$CcU^4xv*_46z7tcaVVd{$GZwdpZ@K*AdMvAkidsl;$Uu?R>*9g3|6K)$HQV zJ*m$-13pOcb~eGFZl+S_I*eValJr0tUF_!28rT`Grk~IZ$X5CSuAPyBL9?bvx7toO z$!ZJdHSc_t`&a7kZs1TZ`nRIe{!~gC@*Xbx^pTu4beRe%woU+YMpXWdcxH7KlBuX> zSpr3PRJ=_rF?%8eZ73DP@u+GAV^b=M;OWpNwLLEq;wddzIILTfLY;7Lq5pN+eROd) z2Lyrf%;CBcI`g}B{l)SozVV|+Yp?^H(hr5a`26=1=`XzRZ zJmKY%;_V9A!&$ifWKPcvx<{#r?k_9lJBG#=}QO+*Tu z`EtcfB$hCut1pCB&?7$Y7de*L%m=XS@jGh2n+i^Q!Zr4^JHK`hX@YWZ>IxtTle$C9 z?y5d3u|5SY{4}WRV8F2}E9?!wEd$6H6ijvk*=oC}m5Ly3aw+Igm#? ze;mzd&Z9lyk2oG5d46JdJ&~4Wd{0hSEUs;+N5 zZJ^U`uZa6tGlv~rVLteCO*B1s!_&u=wH}C=|&Nd9=ZgihXw)Z?(Rmq z5g1COyQRBJI;Fe2yBT6=-s8RRz3cvb_-k0hT1$;HZK?3#tKC*&N(Rj z67a?@a+*s(Sy0?gFPltTe^vVHy4oZF&EuGNL5?|Abf>}S{%Edp@<=G?u>BMD;?AS@ zY4UO-hKBRa878()oQBJ3=ZV=_t00!y+>+ZWK6)N9EoHkx9@4XWc(Xi4w6`)dJeNzj zx%fvY%es0;=+V$TYhF1FUIg?3%DIkhhOx4i>+dNYg(rdzV(q0O^S|-+fRKvNQ(pHM zRN5Gv)_Xk2c3nXw6K1&qQudm4HQ$hS2xVsL(0no{>O2{S_)$+>fG80cc=FNd6w|XT z*LS%4JbQYwB=$U2h{Y|%j?Yd9;yP1u46!EDove50gq!uchZUdUe%mu%Q_Hj`d`iKm zrQ7@uTMekl@q8C;ip>ELmlzgU+p0iW1Ur2ulDbKnXJKmMsbZT>C5hbo@^q4};B0NV zXT|zsSWIUC@{a>0N>5~_!;a>>r@O;bJ8}TF>SoZa^UQ@?WI#nN2|^#KWb;sZD;o>U zre6!ADYi|nxRFf6wOVgr6kn)?c!fq6YJ3>g6qCSL$TTV?1%;B?eiFal(WEx_C zh*zO@ISfXU`hy>rBnNsBi;<6l4go+sMYf_5Z<0?ng7(=H+_504e0?Y+h9lh6%#0Cz zMla^cvVaotiEWF@uhy+9ZhpFa)#J5OLIZJ*@u9Oq(@g0>*yrx4O&<7h8XS~#=keN& zKR=T5=3h5=C=|AQY9mjDXXXr>0qz`t#|Bt`q|8{`CRp5}`Qa5@1{!w{Oa8LoOb(UkD7I|pgkN_Z#vB(MH2O_=0j zlI7{kkB#)&by|NC_q~mO3naedL!j~79R=n}W+JAQz$%;kpSSiRl4fjHWI88ht9q%e%?>_th!k5P!} z934mV`HPJ@as@uWR=o1mL$Ln9Ee^vk;73%bRCb2>PMfH&zWs3tW8t>ak^=em!9%4B?fe zW68c@MBkh&RbtNC+#`J)Wsdj8xd|oH*v229zF(PL%O8bT&N19C?~2RCdq->&@A4w* z;{izi_X=@e*Ttm+)4;b3jEhbO*`Pt|O~O%+aIm;M9$W&T=3vuT?^rOPyP5WRq99E7 z_@Ywc2yP@B*jB0z#1=B^eW^Le!`nDd)@GR2beLq<9`W{a_M-&q!$H|Knd(R?%lf3e z$p^^Z{HK<9+0QY(ntaCg*806wA)CE$X-HhY={}X&C0bYcg{QxkNX_2j!^H~-%TR1# zH_r6uSH52DkD(fNu^RX+mLrwMt=x>H<_jaTn8k?}Iu5bp{daQ2kcA`p?K##~4+c;f z7x?<9>@>KAj`;6s6@2(N=_FBgY=B1K!#hYp9G$ArY}fhby^HS3O*o#s8BYPP6)(*N-iu}l5?<#eQ@@DWNKMn(ljRjM2Q*CMyCHST z2V^&kND$ak1tk7H#2+~vIkJ`IvprRBOo3xzw0#iEgM1fxR1IOnuSmVSNAH$L=qFNUr-ZWQ zWji5P6+v+*K2{a^7HZSN8~o1J0}f9SqM6-=qV3IftTgz#h~q0@$~T%y{N}w3Sd0UN zqJt0uKlmk=c5`W9gQ! zs(ULP@YeNbd&TPoZKsnlx$;J+T6KILefADo)!UbrekX9(>%THtth&XkyJp6-d#!2_ z&;4Lhy$QOXc#0a{ox9qvcfOe|&i^$!*(Vcme6o6QTQ+ybyk+D!2eIDf&tQ=jtzydc zY_O-q!6bFaRT}G$WzKFM-&U6X?ltEcNIK8n{5^8)zVjCOgiEAM?>4U_C|+B54O3qo zLp^d4@zDPlf-KXKcu<8ff+j|^iT7>oiBN*V%4GNEOeD;#C!u<1JI%=^L6BB_<2GY@ zNuatl-4Hy=ZuKzB&XE~IyoY9m!IUqZVeafIT=ngz?`RlDe9Zgd6p604=m=1SKXDze zkE$gUw|=Y;3tg;zgC)BI4bqDCtJjO9576{`BFE6y}Ba`05fRTTgUi3FXv>M!&8(az2Ptm)?|K{(wrq*VT*KE zrq-a)qEPqMdAS9ZR8MD=0(AJLH3uM8lQ38jMG-giUy&8c(TpwmWiw2NFW=K1(8n2!cwMY4g_~r;PGrxN|jofnA$6-Ks2dQHC z2(nkhvTS%#QSjJ1w|qKpS&tW!mya>xogKm!zQ94`F7^{6q~})WzG447Gjj13CU&*t zooo#Y{KS5BKax2N3CS4D8O?88u`xqGFY&17$N^CMZ+KG`U4%1{w?FIC(b9D&jepG--sf)89aApaUf?ouG>1|VCK&_YA zvh)kmq5i-dEwoN!hx(sl^a>7Gj!^na()=Q1-+l}Rpxr}MYP!Mf@`wz+j{qV_$@&?L z8t5q_{@cm{ZoUp7$ghJgufUz=4eJ(tk<()74t5D&>i`S&Yu7`#Z+BGgplTvy>bXGREfdHUrz0C>MeIz-Zx#dPj{0U!mWyx_ zAHwUJ?S{(J<%atBmt6z^+)MDil;%4dB24gwfNYSy*I>TZD4c;HOoP5L?=lhfGw0qCu|Kx!&RrURi|B1(NQmhD~Q|zIStIv(Hv@1w7sFemL7h^$bOn z-&UH}1Qw_9_{aq0(6UztF{J8R1P|-;Qr&&EJks>qm*Uk<;GuRceDmgMp~|%;k7r^4;|Ds*ixT z`tLqZ81xX$m#s+k0=wPWLZ>Xl=T;fIHNxO@+Ok+~TY+35h(>c{1appf4lw+Ar}s*; zqO!~hFCfQKI&t$K*$$wt18#nY@*SExWsO&FGL=6a%_$epl-f(^7O9rsN(mBF7w=Mvtxsck$hzEe^mo&?L_qk6Gldx3v<<2hM z>ScX~haF@91)yDZ>8 z0*|$1OaXC~#r%wfINIjR(?v<5i3*DHQnkvj@1NJVNTBiXJQ>UvmW4`%evZ$&xc!9^ zVVeUL7mKpUJ9zqgFbYZI1j@xP!Lk7agDDJa%v8?E7Yj(JAp~6RB}+c5E%HbbE%zUPqQa8@v$W@gv z!1h0_ra)(}S4YX1oQ~)%XUh_d?^5qu@0}fkf+w9ZqUhd|7yP3(m0-e_a*{_?iOr`&n-64%PCD4A;KWp^%MeM64* zT8I&DEs+Zr|htjl2$Z0nQdgD`d%sP>;if+CDa(4@{(#Z?OAGnF8y!2LaIYd)3$H5!sSm!*J>-N)QqPUj5=bUkycFOT_2 z%u@7)-vSwWaZ<|!&mNNTjM_FMK1zaYI6sLbGS6^+|3{WD{^C`G{Z6bpYu*6sXd6gU z`7=J)5hC$wdAnA|gbm>5N{Dz-wJURhUQzH*I8Ea_Zm_1_e3c1XuiUdQMv09r*9twL zLZIPsKAF<~;~hWS_#o>tpNwJNH8 z=XPr}^+EmYF#=lRjmw9}JZGDJ|0-H^RQJqZm$#K4*gTVv8|oAF*?jV5+KtI(?5wNY zL?({?I3vbqx~X6=#n!yaB19$HGR5p};*X@g?y|>L*enkoX&#`B*s9aC{E}F)#N(rt zZb;P}5roRJbl3KrS)%cn51whxmHNoB5L~VD>4()w@IopWk5b`ezIqm+IVuc$)d?qh zBZYJKAJH?3F9z8Q^dBLcVuQ#`ZM3U}0-rWw#-r&}WqcFhozFUVV_WBhz~4>B4%2v& zkGGB)^akBrq3s&Ao?6hYb9DczW1-{Vj z?BvPg<4gBL&*S>*JGw-BS6x*3Q#v)=cRi7Yh#4bbKh5umSGkB2=}ALkZ`edv-2xPe3ixO;AXN9})b_g9$T6F(c=kjxkPNv(xi z{z2kE>W++fiyngxj~eG_K8cSWts+~?p~DM|jej{9sWHd|GVas+&$~Z|h5fID#Wm`m zh|L$D++uK%VAETbVpw@z})sN(iJaIdtss0{1{0h zg56vEuRP)O+G`XNzU-w+xIh0WEl74Q$KsmFJ6{x#Vz*d^x^6Vb%Osr~+%;phxJOiN z)Ss1ZXZX_;*V#_9!J<~HdC!$Vlr8XGoD~n~QO2x##lx9!j>~T0GU=LS>3k@ORq&7i zeDggc5~s`%0Y^?8$v3E-;y)h&HHLVxkl?RiquDDzcZ!abjTGB9Rvn(RC0|mPVGPts zpf%B6{45y0JrMUT=G*AKNo&*FlwSn5ZfEwZPxEN@LR}LWelYLn6~DB@X7)4xR;bzJ z`zj#U*!tej^Yd8?0&g%oEjpyn3t)iB{fzn|byiMi7fUve8Y+wyKM)h~&zYaNy(@?e z1(4VG_$JmF7Tc66*Jb3bBk06@Wa7V3Gf@r5L^=ez(2PIX_9^S7%xM`?@ie6@do)_E z8LxsndW1L$ON{!lphuI|kI74(itpMEZ+^dsaalR00<@m2o%$pxJ?Lz)2h%o)^|3t> zY#hec=~jgXN3Yip0RK5r8T}LzH7H;Kd)@s%<`g0X61YJl?wN*2Gkl&z!Krrkoeq>H z<7H<{RC18pVQ2wIKPpG;^?QmwE@zakT>$Ab2mnDAQD?1p^mX@Du7@>i;xBfY#P<~_ zhEt( zgRf+ZR7%NKhn^H#8Uy=`u5^-FVFVX~D>?2kL)FGcg)*z8cAMGDMyHhg?eezSWKYq* zGMD{{+)%sLiXmQ1c0>(d!sV5d=~+Oh+SL4akgX!A=)mXycv~%yxB=sT0EOXC;kvp~ z)U4mby+jMF7(j==#6_s&0n)DGl3jUd7c+@;Zd#B(#|jQv7NkpLS^zcas^3og-{fU_=mFwA01-z=aQ5NNnN4#^Y~}<+o3)e4hDh&Tmn5jTUny0+MNieSZOhHlwq$+M zx=Mf51r)P*_3GWe&WzS->CNq6?OPJiV^OB>S^J&iWp6aCGHqK!dEc}fD2>;H(d@(Q ztix`DQ1mK9ka5C-lY)NCpI&^@mBzh5{`I4cl~P1a~*W9SH@7sj76 zM3XaUb|lVEl)--w6A-2H^96u;It?HmcFt^z=N#9rzJF#cT?B6?)W6Ntj40p`FMc0E zAa6V{#k9q<%;McZ`JT`2?AQ5(Z$6r{i{|xxxy@X(%aWxR+WMfSb;dqPwTaaDS$4wE z^>sEMk%=?nz3DNnZMEI*8KW<}O8#u)*Qm~bT($A6ykX6Q$~ULUdD28tJHtdVFvmmK z9cmg_5`JcZoj7rc6qE*x8|o*;aFPjFg0Qr>Fi%+#FhM1t4A1`%+cJ=Z^HU|C>; zBifI`5r@qFd&VN+5Wx42JTOLoM2WsrzeqYzbnD}_hJC&J%2W%Ch`jUXM>ZAblLd<5 zWH4>1CNC+%OCT9Efd0X{ay|rU-G&_&#v=hvB~ib4cr!9$?aXb}kG8%GXzq}rB{coz z9G@QC@Xq_qP*oQhiOtQTXZcb-tJ@bPyt5}V9*7+;6=E^6Qnqa=jdbb92YVP8h(Dz9 zs4{lQXy1Th*`^_zz2Srje2*&$tIuk*zPD**D&l@Ylc9v2-JiF(+#5D6z3ZFYhjpuN zfU9ocNE8qkQ&_5GL~dd8TWbpuYu1{Tt-dkk;Bq~Sqn8(2*&iQrIL)>M@{$-tqL_$5E|jkr#4wB)=vSr zOJUoC{RjNdEXKW;Ndd{Vo>>invOCcW_0v;n#|kOik1ft=;D>@3HJ-*p2Az_Ehu?bX zx)&Dsw0F0r8Fz6Mmu~`cAl|Q1|5tR`|9jS4+ye(U9 z$`yEeu-P~caL-_0zqq?1SgL3PjR+!^ue9#FiLz{LfY;Ehz3H-nsFzb;ugHKLUW8 z$9bWh^nWgF47ks;lD`BK5o-Vjtx?qX%I#vy`EcV9t|?tQk#k-OrgZqe%5v7GtoS*otBU?yKg?yc{fULDfL(c!PJ z_eEt_86UEfi*HR<6axGFket!zGH<`55pf&YG<`tsT4A|w|VK$=F}5FJUsj3i-u<*E8+^fRTpyAa}6#r)wqtmyAi9N6jN zAh_6Ze`4U-lMKGO;hLC}#MbH1g0IfUD&ox3<0&A#k$e&V*06uVPG};YVe#)^TV-~B zI&ob%V-F}E!XhtsliRjeJWg098MRx5fW@UH>qcyFJQwPr=r)Om8K?x?vhPUrBfv{z zA7A_Yk~XR%jlLU9{!Y+2Pis82HQp12if#LMI7elp`X_Uq#CMGHyGRcpQ!#k&rqg2b z#gm15;*ITUE4dj#^BFvn#3rRwwDg_OQkIkE{^N+|L`{EoR5;<}z#fq2H&n!-%q%o< zFl`C^4|Iuo4&4OUT?-wcQjc!D`pEoCkiNOHM8Ie_By_WOCy<-w#bAO6_t>23H2Zt;-}ekS#5lPFfx(J%LPr3i?! zSxXJ}{lIi;6h+Bff4wHGzZwO9I2)s>OrH#xpnyJ9Xn$#+f-BE}N(6L7iJLe|%lBJ9 z%`g_d#IvDFVF6pn9Lb9xqvrzwPcgl1;kTx_({}+mb8<$+|4JH3d^xGkMgvj34O>?l zvX{PjFu3fpwta08FwpAzi!MgqJ1jViU2zVd5^E}UkzE@Dh zh(t_sx#Dr!qPmX|EUpT7^y2}6_80Xp@u(s_!8e!~6$)@P6QKwb9<~ZXr>>ZG7x)#=3(4|rEQiF$>o2Pc%=R!rX`Db~%l$(ok zcj7asgdtCob;1DeRT7ljpLcnW1_(?~Y!BT_D_gF$o62%a+*2vDGGB)G0b@pA7b#vG zy^ScSZ)&zJuNpXeU#yuQPcrmL=fSnn8(4he=MwPPkzSx7s*op@Ik+>Ly%w0N!D<>5 z0M`nIBAe_Z%%&56iL>$uD^bu<9eK-)uAiVfUA=KY_ay?P>xR7q|8HyK8q%)ou%k%_ zJ2-EBI34D=~GTDS`TBUV;hsA7>G^cf{nU)6w9?N~v(1Zc8mV5Jc1hOyr;l~uU zSgmCR*aLY_%-Soe`aggSo9X;Db$_LGXl@1eN+jo^Hd+CfLszS92o^;w;BU&39bT+* zN=eWAjwDO3npZwz5Y*cC}WI8VgRQ33YpI zyj_MpkKj^5o*I{V6I#HvcdQ^TbdTE=NIha6E2fq>XWrv_@s+ec<+6eO-%s-9g|Egz zP315xE~DfAgH zr2v^z$LdBB*vN0UH-OcT;yg6PLX2wmDj~0Iyn2(EDb8 z60Bh)SdHNC1t#oX_4Kd8@K*_kv*m}WUu|97WS>&+nyxoAie%g*qrgd6^PSxs!NlTz z#{-d;=4At2Y|fYU3P5p9tGORZYx2Xs#L)SWbagPU9ne-g;TAW>)SxjL7YV5g>POXg zYLiX>VZ1e+pgZN4(RzM{4ymS)Oeo~-NdUg2J^5?Uu1RwASFKC7?8q%kTgd&t)*xRo zsbAJ9GYaK4gb~roy*)F5)XZdZ+p0oj+p+*Er0ydV5@t@G_L|EzQh;`z(tZGJ)wH63K%g>J|GbiBWq& zJcu8~hI5+<*idbMy5iBA)alm$kD?e)QS-xpv^XR$uev_d zBT91qXt_r}T$L0>+IA|4IBd(lL;sK^TYPY5laC+~&inYD6z6EtS9r_t9T9vK6+0@a zIcGpg2~w1WT$_%S%<)wN(c5}}f1!~FwSXk!rTB@Xv&he#7I!lo2-2N7KgqG%ERc=X zT9ArQghQ4iaX0ADQ;^;b@H)yv9|Dm5jHaBUn5q&kKK))7sTB@-O$cGHhEEIj9^#ZJ zwwJlOAu$LCLAD=@uohPppGnnSf$VMoA*Wh`vR~YF#tB6;HNQFW{hznJ zJc_{1nZ96hO})f_yuT!-wp31h2fU7Uj=!%j4A!pwFZ%Ys!vnsLVy_ii^F9CgE%@(^ zCfMs0hwVzNV*aQI*SUV2)ZAQVm)I2ZnSQ=!61VDXi8_lUKniQKHI!(!n4}FQ@$d^R z?l3GmE(67ApwTH@N8%;o>zq|8Vz<=Pn|(@#D~WHI?<0*hr zg<7jpW;&U)>-@`m$BOi=@+2SeCr>v+c2Y4hl27JPVivtKQ_KVMV(P!c7xQgGUt`5^ z$cq3F6-hJ2|CIZa`2H5qdkmC@4jd8^?a6EM;W~?WD*1ck^MA3JE+JCpDE4}OdftNd zE0%ifc3vHFXw_}*xqK8m4bg6KmbOz+QF?ohlyQ#j6NPrHo1nwH;^OhsOUYq=(cj;{ z^h#J*_*k007waXDp=^G$H?N6X8(6znnLqVzrqThoqt+%(W1>vjQgzw0ELw3ppjxStQGym9r^;4T8S(!pX3wY*+YPDD+E1tx zmP#S-AORh9Hd>`ZixVPTJuCYARog5sE3Z7_7zyMHt>!17LW{e(A|VEtcl0_{N*?en z)92FWGdbTN*LzAJnN&4C*EY)>;WmORf0So?$Vs*9J`0jW)C6?6hkca)kb{V?RbuS1 zj2hHn+V%e5lSktF0n2QuAQJ}xhJtr~Ol?`mc>7TP+_Jpy10A#lWY0;}&{ZG! zt&KmD(d?xjFOLDOyS==Z{q_(IqEdQpqK4y6lf!O!Dxbrb5x^~A9ygc3{m`?=QQzS@%Q_zCrWk0U%*N)&IUYHL9~3 ztY|~guQ|8&I)1mWbl`F<57_!_74PkJ_S6Kg3H3`NZ$rV@8L5T%=r@1qHWI)>=+is2)Q5WAcq|RLPBYO1QYiBK9Nsw7K0{ zo#@=k4|xE-GP_BZwXIw?``#1%jws#oA{r<*m3C714uSNy9!aruiW*-c?!G*07Ru-Q zdUG%&vrRWNmW=2y0wn{AyaxSuR9=&Qg@H3u>H@|bZp~QsfT`EBnmkgNE9BRm$O~v| zJ2k2|)s<4nXNjO$r!S>)I}HqAW2^hbNcJ?0M*2Rc78`66-A3yHiSQ~^4cjh>7wsLw z+!9s&xkmU=IG(pZ;_fD|mPh;|sNOH%k_^?J&Z#?}I1*<4b|2PIuXaxJyuY$@%lk%d zNhc_Xn+s52ug<&X8R6cTSK4moF+dMxH$SeJJHqyx1It&$V7FNpL?h`uRJ*YP=JcSg zx>Q~dnGs!t3FW?d;}!JgWzT+U(Cx(H+JEOyOzgQ9Ic?(U5c!B{&C3b_iRL5hnQjZ?R2HY!3k zu9_`#CvFl^WC2!-t-@bCS-*z7#N9fy{c_NFUEYSZufd#zp6T~R#7_+W}*@`z^ss2L*H`E|Ef9_e( zKeh8EF=?dWc6XurCZg{LqY{GvX!K|EsavNbLo7-uvp^5WHXbcL zXS8~4)+TJnM4X1&^}&>CzZ#oQ-vMy?ZPf#x`415a&>y2#<3ZIuFQdU^We>tXp}4ZE zsyYR?;L`|S|O*9#Nm*RrZxV0YrLVBruPxCDeR9?h6$Q((@Kj+hJ;i) zQ{!pJtgCAyF_J`jv;qIWrOovxB2c=;@j1M|QpzS#cC|fxIhZMBC|rhwky1&O18y08 zYSpq-=*gmO1I6o?hFv|wwCEF9c>L$M#l#SFP)?78=@o$}BjDkXk@dG;s+m7{hJ2-5 zE*}+ZeOe2^-< zCgvNpz_ckh^ZgtT(0zI+M#46G${-wEYeeclb&ERa2lf)K}tDU{?Wtx5MMIDgnf=XHo}GD@`*hBv~ZPu+)YNr{!X% zHtMqnJ5!)^Vn2&lD*xZCK*(GyizS|XP;8N&Sm<@C2I1_Cc7<{Tznz)rugI6Pr@kysnOVRalIw42)R7V~Q?|l1He>__#3EauWy6%4V_OZW0zD#M1 zR~PI4Cf{Rj5mNb`=bnHEt~MW_BYPhmIf>ZcL7YcAg>3M?xqKv}O^+dtRSd0osbrs( zWn*x(^nMS=fMpc`?HDi-JLSrWmUEql@uU0Ss{|=tYavR^0jq%OE#{?sB8e>z*|(ZC z$bppJSpOev_7YwN%@gSsYPtqhqkDF4Q-(k_WA;LixKlSYz5lmck!Fb&_XlaQfr~;RS0TO98qh?^TKoxy- z2WE5vf9&7A4&36VNZtC#jZpHFJCG6eZN(LiMrckM5@O0)iYpsXDFs8{DTc8+Z2a@lddoq}ggFXJ8lwV?h&cVL9v|N@?(0*Cj7x7nobH@yY<|Ar2Ic0<*EGZBHF0*saod`iA=aeZ)hAjY?mC> z@;v7&6nD@*a#GVhX>X4O61Xz2&$&SYrIW$R0fVAM-nh%RKx-kYwy5J9*=)V&vQX@J z3ylBnwsWhW^D8(V1=NU}Cw@}MrK?x$olFk-e^ffNJGk>k_Y(S52#d3H%=lYsZyVCu zR$sn+8OLC)`u&Es5f%@efk`%b+DSn3{}f@ zCPhJN+e?Mgiw4Qt*dOrMoN}wSFz|D@2C8yGXR@GKR~$xEoWQK zFmicc!^N(2jI0*WWR82GyPA1MDCCJ}X87!{K2x8T3v}#z^Z?q;%+P$9v>C(`>6ZF( z;Qc?o{mu6uL{$CiGQb zId%%W)gH-lh%Z>QhmM+k7v{(aLgHXI)lo~tKS8E>xtlgZ=rs8!G+}Fdb|n_enD%R~ zMQzU4EAA?*TcqW{$jTLBuY$p-$B+}xQSf7|5zwW`wzyrW6v{WXDwQZG(khoy0I4p@ zI{Y09wO=VL_qneK`s>Nt5?s$W`g>dW#;yPbas2G6rzEXPDUCEBwNThD5?{pCtrbb< zYw^(7{M&iIgiFx%#?Ket0=95cGGthqmKxrHj=n!uW35$J8dR@T${NA@eILgHU_+TYkL^ zn<=@S$E*CG_xd?MiLSyPo(AFK#Pa=CD7uj$G|Hv&VsFu&q&%wHvng13d`Rg((&=YG zY??a!O=o@txBYMj1OiieUPWx*-Dj%$Nc@5e5+A9BOxe}QTiTOS8C<;^))o~_+u;8^&{Eo zw5qNrr9=#6t*S@MN7;ro-UR{0a()(${~#!3h}7rZq}~>%PhEE~g(x96!UN)Q;z*KW)2kIqZ~(JsM_XLk};BKZ#Ju^LyP%)#Qs%YZT8g?ZvDc7(R1Oct3P! zZ1cy>EvIFU_MP_jmAA<{&r)by^@e9e%4hcu#Pa8fSvD67R__kQV6KKoPg~A3TFf{d z&e)EfuIFd?I4t%OmD24O|PblzOYU(IB7PS?{L? zmGSgmzO&g?%Hr;Ag6*(bop=l7;<=O(C@~-dt>HKQy3Ab*M7rr#96Xi^koK>xeE8oh z_x|6ns#4?yl{AA|4Jh6OIVI1f9Gwu7gU9-;fY`A6=K$ZND(}Gc4*p&AkeUwhPk|M% z8(h}lX-vL_Y&!bNDJ`cw6L+1-u{#5q{*f4OlO^W!c!z)9uN3M@^|cS9392(p^c)h zU3}tv1U$bbXvdr#41kvf&FPu|^16crX&Yeu<3tFJefqHQ8=EI3W*hDlbL>cq@%y`E z-7p%RZtQ-s?ToFHAGk59!%mRY<8aYseqKzel&e!Y?2JT=5w3_=07EvAS;)>=0}n!x zBO4II;`dg2i|_he<8{s!#CJ^qW;&Y9=#jXhDw$ zVe*xJhhD&XLQy|I#sgUB)-}4pbTb+Y z_{n6b`aw{+6$SaIZaSJ4qwAfPlR)=@v7L~|S<5CU&_aG1a>Sqktv`uj($zt1!7dVI zy(c<_tV97{*WM%|0A5(yLK|2DKp7qsOBnxF6@bgQVMfJ7421Ng&vj?`gjEsTwTJc* zT|>aUe^G`2#z$(~*7G8O9R_!Tbt68QyMYLmh$KEiUVA+bix=A#+w07}2&1s5i>dG%y6 zBtcz~5Pm;nw@B?PRGF>NH|&nl9lc#)8rBThM){h-m-f(PU;DviWZ9OsW9-9MxAw{LCV?Ez0MuP2zv@i`fXH6LTQs*FUFAK%s(c(VHv*0}(0H>86qJQKpz>e2U z)53AKUEPy+%?Qnm$T@hbzl@^q75!IE^BvAJtUb6qP{N+W&3rBDQhyX!&>>EpMp=6} zR%ba|j@&G*g$Drzi&s?g%S@^6@>f44A8O^E9t%b9zj#@4@1gw>INB(0h~}&tf47jK z>;p`W)BQ|XF(H(zfzZfOtq6hv3UOv9r;KCJ)X*`zZ;qvwA8H8w%L*WWv~}`i9RR5D z2^ysWJqVXvr6r80n6Dhm@NHrDbRQ2+oOC*iv`c>yYmg>sVLxkFP6!g~Piv27e;pR> z5W5PI5UhSsZGx>>c>Lc?SVX629Xq>SKs!5$RwFD21>4JWT5+c$&QE~O#D_xkDnT_7C2W6qDSgAi6Ei1X@v{bX>?lbe0pq(#A#Jbfft}Up0>3#~DmdM|-p6!}*$8)YEhM&D4Rc!`Sm5OoQ~)py|? zp1v<+Hic|(o3>SNwJ?f|>sKkI$8|v0jT?VLxe+AJ^1hiK>jp)}_FJ$EO%Rl~(O-X* zz=$UKQscvW)XVm^y}0W^pq|pTJ0KsR*U?dksItFA#F@qgo7i)eDofP-g^&idlkAEa zm-*~46r@ZM-gYTxnWt(1Dm+5&*6dmF7|O8M@TqohS)1LjTvw$q*OA8GhE(c{$J@xt z5oJ~vRq!mdZ}x88H01joJt;|K_$%{e0i(Y7JWDP)3Ni1F*s+^Rqmo3@)hxkQzJ zG6!4=YQNPouCGG|B)3OeZw?5K*^JRA9j7@$kR)qhl?edHkY&%gJhOV++TziOa@d`E z9doeJ>+%4*m|v64g^885JZc^9>d(rHyU#(m`2Q`gH=HjK_(O3Ya%G3AY~STqtV0-^ zN;O)$y^9AwphvtjjM@{=8x(FzSJGEFoZ(3iHqo_$t$58G|5W!8MVJ=WUS6op(O#V4 zh8sLM(q9JzW`MqHg4(0$kkv?!pO-xXOBm?&3oeLB9T}RqQAa@xtIfuiYQ&o1q}rI0 zK9Tu*r6bzLr>$O9-;UcfK)%0}%CywI+&{?3IZ%K~wOTYzegad+v;|pcvTgH62yFZ#nXrEic7yH}=lcvb&sUp< zItJg$G+hM$aICP z5=t$)ySp0{knYY!cXz}0aQ1o6d$zj&e?Q(oT5OdNp9cG1{TN&qCx9t!P<3#w|seh9!P8U zw7=$^yht$B2$OD!g1cPhG?@Z$Ne4$*kGAXh!uK`Z@~eO1jzjudR+SddYWOsUdDqz(RvL)9WqQ{H8oHcRfvoir zGO)iknGi&)0lc0O)AMhr$kHjGR?~dgpx4xBp57jIB9p`T$<}B@&hTyc#SyNC&=A&W zZbvbbNTtOrV+Wf-KNI{O{&JhAy+%cDM4A?iVyM!dy|Y@D(7VDydrpP=M0Q{iEH@xJ zoL=N0j>wj;euLzD*w{^AfAK*rOJzz zPHkUhq4zH^Ro40Ie08MqaHT7HRia2j3guuMC{nlZp^yn7ceZ=U z`5_(D`mBi9`jRjxUA&Mqx--T5TE5}RjTWdtMyDutLuVr+gAqPUF+_((GG2*)^Yhp* zU+xUs;ImYLiQebBBbjv+I-+o5#PJ?u(*km9FP;zL(|>*&_jS_;2`5S?JYk+5uKeC|H2m8Lk;wkeV`pqtfIH{OI{rUE+{=Oe0}scEdpWpc88d*w2iq_fAjCGWy zc5#F&n)<3f$D95IZ$K(*ox^f26MU*=BOX;CUe!QK3i0VCgFw(rL)+J>5p==n03^{F zjOCUYVleprmy#-=TxvcPOHgeZNc5#@+0R~Hr3gkuWPPPpX|qek1-+`%m!>cy7BJd% z*|M7hs(E%8cfrJE+*3~h1+K`4H&+uT1Yh^;omIcm?dNU+dAxf#RR4*sFRu)c%F`|8 zHyg-uou%RsLsZ|P##c}MN`!uE)Y;56bp=oM6~ljfCp1F#Zd<*qcIN{qfQ=BS*R%+@ z3^14weta^XC|&lU|GqY8n5&S0yQZclJF(m0>IIk{(F$F2Ui3@61UmFxhE(K;5R}UU z)wYAj;Lwyb7)m`>ZA=HQ-QYhJ899CHP_*xD3gmd5E2ddNH(mpe?~7I&#ZK20s*OT# zL|&@`b+ec1kkt;o*Ma92;m?^10`}qRyC0*j)17mc+*f^#>019p^rZUM$Cbk&gjnbP zLSD-z@k$;xOuivMw5su&p&*B^Z5pTSN+6+@4dgzteKkDZ)dKV@FiIs@n$hd4N8rv1 zwr)7$gKYC=|4^i4B}H1$24J7CD%ftWHQNuioGa%&jqdMQNd7f}$tRoLOizW{W?l+a z$}|$hYdk(cT2KWlU@Ux?ap*MEe8=>HfYdr6=6YARK{L3iqtsnxJ;N=LSw%N=c`D(i z!K_Aj*w3;lihI+Bi^7KEOFa*|#DM+!ny!dMxWy%x?v7+nS&oZpTW+=sSVKaA)S*qr zb1#9G8oQO+;r&e=D?Kp|R6$klBAb_mTxRl0NXyD_9&xXNTs*j|(e=*QG8?5UY-=QE zxb`YOW;k8M|B67X*Q2&mF>`~;p<^@~!&ZlKDI}4F2r5mJ>Q8 zpNGpx%D-=pu!eVB1CX%%QF1gFh{pg# z=!%O9x=6|kS_vc8_#Ue#s=81h?GVFI(-3qHi8fG7eJC`~uj*(j7Kn=F(eoK_>b4B# z_GK=s!R|R4HBc9Rn`Gn4!h05!5GI71l*_^3OL3OlqsKfp9@U3LBE{;%1c#!o`)(6# zq;tb>D;{H}h#T6GBI&RJ@1>z71RpsSr4_1>A+^r!(3MO?z0>>bPDe?VjDR;Sm33L7 z)YmrVwx#iWic}n$BfhR3v1!{=(DFJC(+c&;V=)>E?It&E!1!gi3-y#xKedk@u+!&- z4oyN{PR6Eb6AbKO5%&4Tt9}h{SB+asQoiUa+`NENNp3P*uJ-6r6QB|jJcrvHSkH*d zz8iHk*4K*s6$+D>U%(P<#M8H1=opE~yAfeiTyxPb3<|KXjEeAEGTV=2;c)MJW@YR2 zyg4TGnnl~bJCwXm=KA2t3JP|E{=`8GAzb?eF?E;HYih_Xg`#Adtb}?ys-;9f8Xt0A zp2FloIl~b_-?$)U;XOR!K12&n)8XTdn{yPe8W=?XSzjKx0CW&f_up+z8 zYzZfzAxVA>)ogzO3fLN3F}KDy9k1L>e-q#HMX}@4kVa@T0wE}>(6B<` z%p8D11Uqt-O6uv=Yh?i4g;XrJCZHw|HLGXg_U$v6baB1oQFhZAik_(h=l)Y+A_{%4 zaPg>;g8=Gh;?~%{Q&3ZE(OuAUpT~GaaP92Ys4%3z z^SBPR9XHQXdX>N>+3SEH%C-KGTku=iG(toZQe+>^H%36Xl0VE~dYcn6m5E#QtH~9F z3x4fUYA7wub;9yxpWGpc1UDpy6NdQ)4mOhFE-2Vq%+3~kQ~_v^usXaGP%n9(VK!Z& z7=quqEG`xPD#hD-#_olu9D}#}&sGw|YYyXF8P(KpWd%wwN$^-6`I{;JN)%^th>2-BcA91*%qv{VWf6BfsVG$=?N{uh?pln8{8wIV*FCdHy*i}>v8r@MXU*7mh^jVA8 zqH#z^-K5ni)9dmJ**o+-Q%rH(mw|9>n@%OM+1d^pCAyK6FqINW_L0SPO0U%4+MV1u zZj5pwn4(>e*bdnqzeOZ@M%{<vfl`M~g#@-vbrB(+R(gk460TC} z3w!3a7f>H2t#Xl&3B02hUZ1d&gY=6}qwtI7n$`A2 zA*66V+Z~4S(~Oiko7iC>rO1W3C%oD)uW^#AGP%I?m1765Ri0@A5G>9i0+`yUY)uAY z;blL0@Clp&2tadj)-|{f5J>J)3Bg&!^HvK}-r5;co7rzMWeCI$Z}~=(8_N|O9ddJrThW0^FZAbC z?-t(2aJq-a+IyiI2@4#HFPeM10{5;6nl?~_{q5W?UB~@swZOAKtE(>^|t~(QvAlYU9ZpWsY&|&nz#|9A}RuG8$KT(N@)(cat0Nykp;t|EAs2?)`w* ziNr}Jj$c?Hl&<7ajV;MGi7q-O8?g$JgvVw%leg$3N|q&Q5jvv$WcqF2k1R2x<@O$UMAi15ktvtd^aA|Y*$4!tmbVpK+lG%+mySVjzLfO zh1Dn59>Tr_!!0C43+tC+;L-6VHp6ecd(RHZ-&X(VGzU_h$_t;QPtdOO#tQ)|RB99k zbdlzvF+h;WcYR;LU16$mR`_%ssAm!nCbIqr%5D8R)?W$9X5$2cOLP~aRuaJ)OdIAW zpgXBdc!oNFhoz?H(oZLks**3E=h!q|cWN=nDBSAbg&uo4olc{|>F|0qUtV^uWf<`y z_(Ok8mMA$nfqH^+3Q!WiiCuH`9tluxd$G%=RpQ7KXSK^q>?qc;g@}HKFp-t z6%LeB6wNxZ=p~)sHq$*bo!S9po+E&w;P*w4S5KZi>28yaBi!5^%b^5(@nN_OVJ|dT ztKuCAf#Eyi8H%dk8j1#ce!jF?M`x6cbNFvQ=y=En?YU$S0L!J@mzDZ-;>%4c2YC_k(YM&os<^I;pNI(B~?eC^pdGm z#W&8U@$?EcaVoi3y&=hy1Z?gro65cOg&M>}bd)3V$8Xf>XuZ|~k!G8#=)DeWbG6HA z1!V$ch1GB%yDz+wp_)GJug&(;pR39=)acYZNcs$-K=qPPpdz&J+j>(Rwzb3{$8IR) z*JFT)r?ct#6pdX7Q)oOStCQ=1i+8fmH_7C~>62h)2RV<1VFAsU43YMGP6bf@Bzq=N z6qY43d<1ZwRe?&SoUl4v28}lg4{3*x(mwL0@*aYcxYEG!E%vY68(mm#8^uZt@;QKx zxyteJ-~MjPzb7HJijsCF&EcK_{Vw;{ky_o4r6HpK0S12g#hn7kI}^|0oyq(PP5$LQ z=#T*8$OAPLzXH5}9jbG3pgHF4`B=5lFVFq=KlkB)ah=b7l)qlHKsmMXY|5_IJZ<>+-&=_?&*fP-h&&?|zGNA+G2=1i;E15J3i!&`mLt^uhsa!SO0&n z{{Pog5AUES`%j17%2N=ab7Osl+$VM52bp25Or-#sx{t5R5^B>O`jOqc##;19!n!@^!#h+M`aa0xQ zpa>yMY7oW4he%4=yf+7@{Ap$UKI!XnOE3OxWIhQ$NR&Vi#iV)`!dp`+39u7qj*pv6 zsHRVw?Ar+cX!p)vJzN$MdV|j!O2mnQ)ImdWv5p%&{i_hcznC)n0&o)fD8g^1mzMnv zVDNt?hyCj>{YW3i_dON{rMC|P#X3Qj5&gg2*8j43IB>v{I%!jkxe){k4K1CkT*&-! zZFES{ov39bnm5dX(|{x(!5nRp|DWvKcRG0B7HB6p6b#5P2ejWLdj~D`{OhNuo+2ElJY6hqZXmP*QOpl+Qmu&kh$Pm zNf>X<;?UBOdV|FLz=EJ-=46@Ele#L)<3-RelPu{5KXT-+WfdeKjB5M6MDc#-ohjSN zO8u(aZCITyx4nZh;O1`+PPC^C&(_%&v{yTy@s~Lw-$g2$av%mdy6?@RLK)5R@`#Zs z(DC7gEaxKeIa8lI>+3Us*MJ+0hUWD5C?{Gl?XfMzTTggYumgCvv&I^CxnmFu0%T8T zEA`iOUfvxUY>X`6_%;R~$pU*Go>&b?aTbX4xsyr&DWQTr%h{)uo9ZMU?J+?6>-LMC z!^vO2&39oqhB!V3B&^yy|2KwHW-(14V-n5B6K~)wsT>wt5lpA5J?4dhnW0WK!bJ>d9?VeX>1C^v5tap#J~dORHu#n=J20F*4*=l=b-}EnF2ToB5r#z2lW(ik)GU5%FB5l3$KD$Z%>&yc77XLCzYa zV5L==m!n4pCA`68)8uVmzQ983?00?ZXHijbC|2w!+fwc(t=B4y#?5hZ!?~=*I%D|q zC_vlXyy({YGM?}Uw*0iby6@&$YOIbT!|CNkQjoCrwF(SR0~@}Robn;Mvn8rxei8@# z7#`OA-w#GHBFPWCwr?9N69-|nyKRBRiaq(Lxy9#uIIPZnWHq@E+Lyww)I3}qmwh-G z$PmGEfr+oCEBBz`ISLZveLIGS#AOC^?!E*dD4j}rvW>$H!hNZ7%uub4YF3}DoSq#% zr)AZq45{WA{m|_w2NX<+WM6sn1hsQd@q^TSsD7WULZ*_H@tuaIWGHXVXl9ssKbmyq zjbu?FWL_@$`g)o@kI<*_(U+|&dzh1UR}_PBg*mKN*ar@Tu^dy1??4t`tRD6S`fB0k z6W8&^NJSKYGDXIY=1EKr{ak5JqS9PqGwMnft@H zG0AOc7S_Xx)aoZVBUM36u0-(Ljb;_~9T2rz_UuA}XG;|OBIb8kdU1Mna5%mE5|s>d ziqvl#c9rz(1-B3&d13lxovuE3z7q@EM)Hdri^5q8w_VEwI9x&>!X8sO45XY7+9LeC z`S+unW4l!gUke|fw00`8UT+ODic;823Ru zy}gTO5pR6?8iwhy?Hc+WpZdoM{&~h{ui0l_!oI;B*~D&;7rLox7?kSPpA8AhYp~~1_*9_Ib`fgX>QmY$*0;+<;oTb z|0%87;gkFoaj3iES+6M&XKxcI#7$5NJ9h1AsWCUDEL+Ft5H-t1>TuM9!kyfBJO)r8MeOZjHd z3zb~^HdW)CWZ{0{V?=4)_6$l5W^_d>%BWe;k;(p8$Q#@hL6g(m0|!qetax`VjVyv* zpV=SFw4=c}zO?Ngl=LRUGK?Bb^)mn)Dibp#eZiVxupvNadqcJ2fAT&q5|z{i(Z38? zW;9o+7~BbLk!ZlsydYnO|MhN|zk{FW!fin>y#9WlpRJ?&YGyD%9f zxmlQ_;!u`!ULNxE4mvJdB1NlrHQca+f-JY3GY6dt- z#h(%3gaAD(WZ3w!$B|a6Afb>a-J47esDN6|s4Sp=`)pAq3REOrMRZf~0D-hSAp;bl zpnyw99~Ul2wIBpA1`zVs+TI}Qw&^Hs@v3Ll){Nc&3A@~e;`tr;AKRT%qY-l9KQ{HB z0#ah(jI`VRjEx?9or*@wLo0NLb3a-XvIY5vwb?vY=tB<8MWp!9%pWr55-K8c2#Ev&`Z#mTv|j( z;031bxFTG4^L_(q7KSUBFDm2*$Z)t$x+m7&Cx;dH!|+==k!CMQa9(e#=6fe!I? zoG;M_B?%K{qSe}l$8ma5n#cO-C^{uSac9)2%}01+Bb&FHubHReE$qQQx!E~6?(lVPqqOP!C+);bt+W=t6lqF*B%M_kCDmox$2erV2{W_by*Me?I+=`X2f~mKONev_-cW5Q6h}M53 zl~vx5K0|#UOV=Gn=c^nT5AKU(5xzu3>Kc3^1T6`al3<0s&-N;Y_d-;jM8Ti+aF-y7 zgeG1{;R3ttXp4eLm}aYgO-Vdjnh<~tz3iZ*U&7kcm>3@|}#&c_K?29K6nB`-T=02Ky+U)JrU;l~LmCsgig<9u4wbf~4wdkJH^ zPg?EnAk=Y!J>m5d`6PN!gcH*`dc5;fz@_PuwyNH-u2XA|67r01zA%*TH3@5c6e5SP zke*@k0~ja#8yOj z2x}#~pEhEc;&0Pll1fgOVl?00tk}xT2J^D{=Q!KCYlcuOi4XG1@QTdK77%9(%_rwm!s4kNpGQv~*}fp! zWHo$-m5qMs!#A(r_SWXDPWo6uY{&d9-%me5^r|329^<5;CCG>A4O5;Lu&woLwWQ1E zun-oP;KIwAT(37OxgspH-k&M~-n}UcDo)2i_#3O`M7_dQ141MRA;2G^4xji<#!lk< zQ_VU;JQE);oDTQ7N5%_7E10Pe5-xm@cFTr=X4PIP$iaG-P0{fIS8zizgsecVrf*Xt zUGAYaW3)i9GN=6y1-j769B)K+_rbYUl@*?+(jBg6FztpX+xkDdDpcxdC}ducPzKDM z)i*a}eW?exvF5pe(VgDi5x$`Z`jE2Aj^QA612XwVdl>v`<)0?X)CqZCO|$@H5ZBn( zi~hMd1+0LRJq-jpKRM+4#DBFs!89He986KtQHASS^eI9xsmNEU-EZGU8FUm|Dtm6F z(Js>}Id^=2w9&7AWXl5~7|l`drAz?o47vgT0jk~Q^#rMb^z_+_%L)=M&SyFtG!gS~ zd8%2pB;y#*-uH0M1xH~~gH5frmUGx>St+5v`gJS!1IYlC4Jrt^z)Wtp5BKy}5k^8H{Jv_*lW4jW(6^Aa-Zl%U~?u=(k{g->J?$TM;>=5bC z-{3d*>bPeVD5DH!%pNfq99JhrIHcEqtIV<5Tr!i`zJb)Ks5F}*@-oB;Ff@TaS|>I|bH7BPp7=4AKbU`vbttl6v69eYwgR!exj3~U_P25B1{Z^q9z73n4#C87EWb+KC&Cf{36<|MQ$jm+K)jRFXQZ*J@;9nZ{BTN6?%+8yVnG#CT?GYEE+fnvq< zn;XRdCnaO`#?n<77g7Cxv;h8#%Xq=lb%W3CyjxEqvvatThndX#fi?xJ2L^Z`ha<*6-gr-w3dk46Pbzut zJYz&4Dmzui;jdC%VdQpQ&4=Lw@&)q|OqRQdsSz)ib_29PfardNfz-C=#OgMNH^=tg z`9T=BH$&DGF>hI_HCg--5h#J5uHo|8;3%T;l^l{H`yS>*BOCphat@|XeO;(;Ik)gM z5vCgGx4ywvp%35*taGQl_yypjd26gUgR}G(D!jDBEgv1r{BIqU0XFvY%7T(c+lbeh=(OJk%)AF)>rIY{^Tixm;0G zTnwe7{C+CG!9l8~5)7so*HZg!O28}Omz=K~#8Os+Jp}ZGcm`49Tx#_i5J_$MYaA`Q z_K<+Rxu;7?{6F~k|AyQoj2MEs`gMNZAvGGWb6Y9lT~wGblf|L93)aTJ9#y|?csyQp zX&}qC`w3G+Yjt6xOtnZwveclHqEkue5QvebYgj4Q1D9!dPrtG}Oq_bI?;ZckN6uT3 zepj3(isI~CBYC)o;V=U>*Z&xI)~KoV4^aMf004$JIcd+({~5b+2nNRYO4ho5-Issy zZ0A+)!Sd0q#Si}gmOI~LPr-bue}wWKWRPY6E^CPkAQu(9V!9&y>y`hngn)yW;Pv^z zap^skKIu0q{l9Yt#2APY=*jl@cuwuXb~_g;8==cwbfg2o+L8r)~s4Pe+@*D|8uS{OiT(Xs3P_{Kv7wWtM2X$svRonQa)Nfh&>fRjk% z`JVI7^Yq)g_XJKXA0+)I)cx0s;=Rw$3CnH&=J5aZaaIXHP+NVAGJo_(AUWi-@BOw$ z44D1jlm6cak)#kSIRtyW>7k3;pXaw90-P$ewO=$LDI)KTO^#@cYhm3S?`H;b!6?01>hqLtkOcG&rjk>NcfZ z-OE2Iq)QO3M2k=UxRs$%%ybBswr0TeQ@nqfIpe*)zbXJe49&R95ASsE8G6O~L4v8} z@mUaNcjZodEb= z76QrJ{mAOyJU2g4H~{<(fx9?TV=J6<(4{D)HAnCcG@q;-&UH+cLde^uL-1aU0R#B2 ztD)wMI004h$3Zg3y@oRak#u+@%#UPIjM`8@Q@<-+GVD4C9We9GhBM5+?zq-?!Pjvw z%73hyFPp3;)Sc$WOZ}*&0d(CIQnYAPXX`b_%dH+=xV56+0Hnq^)wAZGlJjkW_(N5e zy4{Mq?sr#7^tz2&fD|-^!1caYIw(Oycyq3)DPO&;t?0A7#9`j}&)#3nncy~hwo=Ns z7khqyXh7EwVeuJ|8Ja1bZe=N)815S;ZO&?Ua16h7A!exE z`qibaX*LUkt>c}C@ZnW>45q_!bYs;Wjbd(fxqCsu{L62E{_s%?)ZBe2FK@XkW|qD4 z1fSEexj)QsX>~^>fd&v$6P;?B5<6V6bvYF<>W$($idwgrdv>Qp1qLdflo035;~qpR zBxs$jZZS#ZV2bwI)I&d0?e`1ZsZmT-Sz1QOrsy;{L4h`t_lo88nw2sZu=*S!wy@X_ zNsU9~omskQoWF%wXu;pHIOIRJWSTQJSa0i@128D`J?$U|{qm{#{{q8M3*;Jxy0csA zM_+=_zg0~q@SHV7w1LiNF+;aq=VRFaf_#s?d=-)&wg9bw^2Uni=g}Ds1=l^!4Y!YD z%7#==Y$?f7`a2b~PWS;Q6o8iVT$8-`@5EG!X4gr`Qm|UfEX4z(Zte&nFS_HZWl1O~ z074H+H%}y;Dr099Q<5|Zz%>{A_DaCyI+j&4t8YjrlwV`YY|yYzlCs zjN|qUx_2gNeQ%;SzGks+g>r$G&SE>-k+@p9$ci)48HEsZOQkn)j7at^F4Gn>yLS+2 z(U9s~U{4mqrz0Ok&#T%uS1Tqb_(fIW- z%#5$rp*_0gGKub$IlF7SXx@cKxXsN2fDj;Uf8|Gdt}>&rVWa-ero6Gvsgt;h&m5AD z0ui)2B14(7SbDjDLU|tZ_wz%Uv`dBjk8f+xZR9iiqqB<83!gJ+llnZZeU3~f(-zR4 z_CgN1S-N*~TKP-SuAShZ`z@ zCRup8`Y$YZ_j;`&Dk(}sioSF@<&y`E)uPCu!n6utd?&!Fe)8Jf4UDr+$K#9rKU#K= zxb+&folm=iJ_Tp@#IQ>B?~{HDJoWw?O_tk<@x;TXWFRd((9$?dx)- zPq$$=RQ(d=QlaVz5fWA_*)s#tc#2OzE0nF8;dK93GO=P|pYYG#zSVo5Wqh<^>Y@uH zIQ!if*#CsYE5G8_N4~bt;jD%a5@O3rAyq~9##L*fHdQBsPSVsz0+_6kh2c)EtjeTL zwpybMJO#?L7Hc2fB=YOqVz>XCMTOS#mGJRmiFbMoxVJ_jQ8X)%W%l^yA0r2Iez_0ytxteAx-Y*1Bd ziIDFpnwRCqguA`Hc}x|N{f#eZ#EOMLWXlH4)FdPx*bG{7xq0=HfRe~-VJ)5hFUXQW zPc=pA_mozXPg3QBw$+tj8TwAaxDnBj{0U2qKvmcRB>(8Bdb_pjjW~(xC4oa$k8 zaazob*mhK4$Gm;T7TNO?W1C7mY@>w^Nkoi9zwMh!nKpeXZ42_LmXz_VUv8hi%R(ZN zWaIwx>L~Y7wm@mS=4qLBPJ*&!i=TTqMcqKwR zlYb9}dM%Bh5j<5|9OHjWlx4lfw0`p6fGECUM+oRXH!v z>5+{%!3VY5nOdoIe)leU^xn|sZEqm}R`n8p5vV2mdu|P|s?5}-dO6CboE?$oJSVvl zue1wgBz*}o)vo>Gt< z0z4H#N2ZsW)iyQW6VS(+C$vd}ZN1Y5Tc_G%NS2ulrCYPk_9rjCX_i3Dy$cc9=;Z5* zm5J3S)PGz%=DS(r(vOlltN73g`o%V_Tju3LM8}b83Q+0qV~bc0^IT^#&WYr=US!{5 z*^Gh?!|U?50H)K@Kv2EZ$2!PLfN(?6%s=h*H1oA`u;R$}6WKX=bZ9D~?`PFOB&M83mGFp(v{Ql=Tw%uex!!67Z|ShjF+ zaVAhK!{V;yOtp7uuHwu3Eyc%c=ccZv4}rJ;OOoQMtR-1J>q|9rHDK{|+!yVC648aEv;nDy7!vh`X<8-ahOHquK z#)ij{Hp-Rp0jrWu8I{1X%{jXi5h4r4R1KhKUuq-0yiQc?rolkb;l}^eR-ATleeF;-n z@lVfY$YRO2PUmG@pJT;C4(~HZhcx7(r|uao)dwZ!l4;2N~D4cSM^u zUu)vhmK~@|eODRa#x2*@gVbw`AtvzJx1H>7OMi>SEHKsOkksh3Lv`g$!;%u(Z{;TL z7$IV}CmGtSSs7E$=u0`X%$#cs)#3zyT*N{=Y)F0Q>?@18Jqj3=KJB`l1{+%r6uH-c zP+n>p8M0kiS;f#WxHD)u0bpv`-75%Z3AcT|j7{M<`_}?Xe2!Bm0NYYt!%}ekQ-cyidjV0jiz^7Qh z|K3CY-c53)aPUy6vA8VKzkSVrjlQK5B%%3O7xabv_c2OfEXSb15%0gY<-Z?^UJg9c zvvTX>o8QO&@8kZzU(AKK(-6F%zi$<}j~>HWV1S({zH_qDB`R@oxPt6jBv-@Cz@V+5 zsJOHB#n)Hbz`(~vmyeuW_+Auh3!s09(W-pwJBx+p&%0(tM+Z<|d`!eizuz0>OCLG@ zwm0OkJx=hUfJWx6p5dh@3I@Typeok2!(IJ+p~P+C3t? z;VRv1vUDijs-E3)?9IGq0Lc01iYg>L34QJFHcR1EGC4FJ8X8)&L%>5k7na!nUJPhLz_>$HhG<~`ak_>%H<>=7BT|voF8QVXS(p>)YYK9TFJgQ$Ts3c8B zL8kZ}pb}2CCM#@-V?WJPP@w3`!9~SBS9SM!dn$npgUYe$F?MrH%gj~-J~zTn_yJoP zoNNNyhfW^*Zoo7vZ}UplCU+J zBdl4oG=_D!AWlx-GDD1uDM7pAj$Tjvw=;s?1&2rf;;0uphReCIjx1&=i4>8hpX-RZmx3~X#J75<8;=;c@kWeqP@B-f^R#$Hh-8A>H z?r-w%qCwX12q#%A7TIGs^n;K<8Ci|(`5^23+0uY&o*-@Z8k>54Sob|j8_>|HmMU%a=brQyPE5- zMTs-mYrIit&`@53e!is=R}i#E3;bn&f(2DC@C1yVDoLP~O5!a5*;h#CW}2t;?)%$m zhdqh(e9N%V&=_wucNa9ahndQzV;AtD$FY1L*uOpIgSqXIa{(7sSztOtznZsDlm6b zML4>QLN;klJ#4VDX?wVsXX&rA#{e_v<%a%5B6Hj_n+v74yuN8503L4HS&B1uHjvV? zYiMFD?wdeJx*9)GV_NmGm??_{IyM+8H`O;O&|}S5G6%EU%>69Tr@4$JG9+~gyK`Ba zE47;yOEQ)2?t8nGm-e&#<3PR@D&2zd_tz{b{#RX+aaA?b9*v<#FPa4{U;lED(f7Z8 zrs^IWl+a+8JPL^`jEH2wT&_l%Fq?=r5bs+LUVlX8m+qhGe7eq<(EBlYs=}mW*?6wA zh;Dqs)UxU1an4Izx&tG3gRK+dXLrE_Y-#Vx*Rzvgs#>%-wBb>JLU)H+*=59IANJ3z zmsD>C+(%^AgVPr6geIM$_oX ze`xJv8O$-}Zs?8k)?_*XnQo0$gz+?3OnsZQK$o~FB%YWH`Z!f}mn_?uPoBVzaS>t6?MEwe+9+#T;(|C}^_5fv5T2nv6@K_Z~2A04^Udq^TE>yFe3V7-@oSEF~ z(DbXdtHoSwTDJ7sY3kW1>zs_`(PL;S$aL6P2H4qM-jYU{uA(9$9c*CUMLhNZ+>L?4 z2$%CcjKg8ipB%){pw$h_Owkpk-CEb;W9A=ZmXi(~-<29&xbQz(AA*fP@1UX0FbLmW z*Q4B;O_WbMt5@#e$QN!uyRsNyV~T! zbZ@Vp7_Y}LsYxnktkX$P2@R&5?T-|M5$QE?HnAy+xzQr) z!6-LOTgexBhqO)wiiO**G58{oppEi;)5|l6-A|-V{{sSV|_wX>Uwm2iPtY{S1Yc6B` zOpOZG;T??%nPSPjhR%BHdMi@YO~iD(40YK-Z-$Ns)BU@NVl7& zKeUr|MJdz(?0S|wxjxVsVLv`=VFB5t*KQno9Ks$9rE1(ADJdvtPHVEEQ0Z=kud>j= z=ceqX;X*(@i7!mgO3P(uiWnQ0H^D+8j%TuB3A&Q6rH4!h<;A>; zn#9Bsv)21iy;Yz~#Unr@hijVX1L1_u`?b-3Gf#Zo;OPaU3S|FoDemiZRT zlB6w;$EeMI?yK7&EnfQI@~EDk9{*^rYOkYh5szh^UTG;d6 zIDZiS_Ee2m|4r%cHb1^Z0Ec2xecFfm6ZVfvMHUQU_QrYUX;doxwVP@(KLcRWGduZv zONp;2@gsVaKv@I=SN5CR&P~A%ncsi5E}N|s5Wkv+hr(r0e|++Hp93E5tGoIKI6mTw za`xusX90l)AH?rQm^%}wWfL~6`I`u3%Xb*$a1|urGzD+{w^~Dg{^yfEgX9)$GLD0S9_upg<@m#LltUcUtwitS|DKXU-g2Q zvG<%!NW`%G*OW&_*{UhaOk#Ru_f(YGTz7o*f+r(}p3`58jzudE#w(AoFI2Dc4Fw0S z)}F>T!lYd*6TH`AO=*f!Jy^3u94}kAR+m3QPK8%Atv$I`22y6Mi5;`bezuD6M8RKk zdZd5eBg6Z9Obj3Nxo~wQ9Cb%Snz*bqwh+21voJWwyoM(+BrgmaobT2bc3<0mey)k- zI>*2K7$fXRJuA)_)56FA3i2PuU{ma+)w@`ELc9M+Ry1L1EMJmby-F{PfY+ge(I^S< znHq`;lUFHf-44M`RTgW9^`0XPMwxLNn>LqRj)&tm#4d= zp*XD8#}0c1Z-1CihwbpJup9Q~9Sbh!Z%)ytMi{SgwGIJ=N(_Tr~$tc}h{l=bW zfD@4d!3k}U|L^yKAUr!Rl+nIO)Qb%5X1CDH(TY7AQb^ec&uA0lfN~BrOPU3tbsMvH z2Or^fdiqFj?-b!r^ix5ISWLbl5$r^CMd&XVPCuTu#+x4mqhZO!oZNs94*=}yfPsn$ zY=xTuTdh9=RNGdfGMc8u0o`MmOpfW&(VJeBfM(pl8gE5nP~&BV&J%0^8uN5yXC0WeyOSY*P5wkEgvE(ap=aUzP_gw zlTJHdl!i~5gdUZx#35~6^Lo|&{Zu1{!#zwOTEV^{-PXpD3EnDQV>7y$SL!^Pj)7!E zup5EW&L3Ca>V=UT_n{q?YUd3Xh<(GOt7VM~Vuxg+?MT23gL`k?5+yI=V|u1)-co~^ z#lwWxlFy{Zq-g^c`wQX&q8w3U1ND`GKISVLJa*!M)O9sgjT5?W;2@uIS zrxqkh&R{??NRw%3P-2rMHBGz|&)MHOz1`pUms`QkNj9W2))Xqkl!pvW**TG0> z$gSJU*%q!_4HxULHgM|4OYCq(ezb1!Yw)AOZZeBM3VRbvxSsBne0|()2e4B(i;k=7%r{qa}yJ;B~cWIVU z!;hT^>(rFX?}oATQ+3g27O`2g-ZP_~H3B9%POFgyo8fM$Oh?93DR4d&_)ljOp6H)( zHWRuV{PYcHsL}Z!S2y_ELAt<|tkfa1NTS8}u=#9wux)9xZU5_4=Wiq8E6aq~N`aey z+lL7z6@g?nbA;Gn11fLIk~Z-yAVb6^yErE<&;d=lJ+YoIg%K1r`L}$UBkI z)-WVqBAjXyaGpolw}KaC!P9DyR50%6tw4!%=OT>0&s<)6--F<&6nG*bOabFx!U}~= z1J~xHceG$ef^3CYFP6=G_&R-s2phj%{^BZ@@>!a2>>{&FQSyz70*zKn|*)Zy%`Z8JWonQxl%NfgJa6_-A0 zEy4C7kHe|dgzXa}x;<>C|IR66;V3_nN(z-6C%tilJ5<#xSJBXbiX4>{Y`9ku%u`wUI9-VgwNLjiv9?ku@6P2hlX6-_yqv249?= znO0p7a9WP0!TmwVfMm;-JpCS+wE#ocbhH>?2~MSw&%0a2YV%j*Z}b=0W#%Vk1{hCO zEB7I*`^7!RH$v5T*L(d3UZx>~)bWK*qqULN*w&ZIH|KX9A;d}w7N<)~(wstk1!sI17(KO70F%?TLL+SD$mYJkhKmcpoq^Q3SblP)iT)q>Qtj{!=f!_6Zz>&_v z?&*=${_-g6?r@c0T0Y$OEkBJwJ>L%2MoGbBXHpLhcdy$c3Z4|U zvXl%sQ0fOd{q#}1x3#kBDiXdr_wh5Sv+J!<{=C9eR{riKm$J8`CqQi0IXa`s6Yhsx zbxfshamjmO<7=@XJfjZ9+f+o@I{|HRCIL0N66sSB!4#jwbK?PIYmSb=XYMDU4dZ2D zi`b$yy9=G9EH^WpaEg$8DJ1?>$bavqz?*c@3n=bvEIc~mIc(v#HCMOghs7{X#D976 zA+g|_Iy*~ZBp4pQ@oPk(#1KRM-_ z|GuYx&Cj8kl>QUZ$9nuDKsYK4D9^M?y-`+C5qf{EaH{d%J@TvWYD)SH(K@gnVno0F zta42|IQMpT9L_7YCjOl%y}H*5x`@WS)^4rw$O?G!jz1joFndk8l2jY6td(Z_TV`LX z=;k*PCH3yEu4&Jx#ejh%Ssx$E2nF_ z_rNDV574&-kSXp^i4)0VMnxAdj~Dl{@uBLXt^P$iy zkywne*oB;Sco^R21?mu18LU<-j#jPQu+8+Br5q{ee0zo#1{HO${7*inVM#xrW2b8j zk^f)N*k4;f>fP1!`z~6rfhY-Bv!bE859!k@&xpV}S;3w99nHX7pu4+=LMitk0)*3l z4$@;Qow+O*{dKMXOrcaOHb}jyyrKMn9^)(`V1jeA+h>OUXZ){EI1PV<mgAp9{OR`7e@WkaZOLpwgxy&A4@=N~w~ z|Hjiz?C;+#mi;v!J*xLE-*wK~?H`!OwN_7ay}dP9im|IgvPsx1!|Of(t5MmOL`p`9 zre5}n*MIGVLyX6aCC~I+3$k1G5ATcw2akyy>#YjL#s0NLACl5VUsrDF|GOu6F-LO5 z;309uxaEFd)iO%KQd(^Lf?Jpl)UTPnE)B*=w(GC|wrel$5?d)}1m4W~`+InZ1IFaq z=e2$}PIU3$0siOV{(_SKUz|C*X1sA%cFf6k}5FAjZfnI6FATk}z;dn$p8$CROAvWaO= zaVpv^gM5?#QkDq0D$q5YulYd95utN*uu4y9%n`-w@zoLiV-5d6Pec4Hnu*`Zn7WSe z$nRu%x7>PQ8tv1n&`%`Xu!ccor|M_!hlx>#P%Y5ffACv;R zSiL@3r7sAXbbfZjZ@po$tt>Gph*#@&{noXt#`SdC5W!Ni0*h1M&HiB+%<2M8^MGFy z_FkzFZ)LZCFLn%@U3mNUB&o<~ywoWE4ZsN3-S%!56-|cH$u)SrGd@NyXTj-WQ30H~ z#Y{k{w(@0cT-;(=(`Kv}!bidPU>&IkI%lMc9j)a3AY`*Sg$$Rjza*4rLStnpU`ClC zEO+h9&&8k$Z;p1TQp7ey;p-O}%P2Yq=n#6VWAmnG;?*YizMs+}k5}e36jV}EL_Aiu zNzi?&uZP*6T|d9Nq9aK2Iv;p`K1(UK`DSf_HY1O8m6ViX^vwcvSyb=ymF_MknI%SW zH+Dttyr%%g#W`fR7LAw~@BIAb2bF)>)s~|z3s{f4`!{X3A#dW53ukjZ; zR!|bqcIPjLT>xKo>KoKl&5&~}(G3m;vFW9s_(UigWc<9G54D)R{C7uqqGM(T2N17| zFV@{T*DM_eU>+%rL0pW>lxd#N^T&@lmVPqDzyIheDH-t}C6gnEmF8qun$)VdD9 z;*5c?sK$L}fyQdPKmSnpW`1!jt-M!Ydts#is8l-pJMG(efi=N;M*ixk+6M}&e>OL2 z)M{#q(Af0}XQ1q1B&bdDYP z?Ah4Nwfr|flxk=dA+kmoCKd}P++^;e0mnFwXZ3x?H|Z}|Ud0Es9z)QM3%9c9VlyRu zc-8dEOj!Yzka*JgxW-MXb+%V+#)2jI!;^jy@D0;`OfMrA_nM+92uj&D`fS4>7KQ@%3xn$*{QvlL3F0UqMG^Ltsm_T z$+~j=DfF#@(jdE09jj>C!LhM;Gh>Xuvy~?=uX@8v0=VQRp)&gGK!)fB)mX8vO0j-< z<~$rQ{RYI#up|j>R`Hm*2r2n1IGY;CvF$=hl(0r9_%YZr#CWn;89fJ!6R!ocb2l7d zObFZ5;cAL1>z_QmPQ#4-D;aBQtpzM^sCqynj*T8ONge}dGQl?V!ONXG{j3Cz1kOct zYNy)VQt0>ddwH2ra9bufRG#`h7RW}l{-_S+m<@qTx`Vq+3XABhP`BVe&yPI&_bg}lK z{2lL-L@v;8nQPM^qbI@qnOJ%ASuM@SpTj2|HpNp?R1=1fXL19|`j^U98I#<% zR!#NGot=t+!Yl>FzL%e>zPMWt2jAnKU*MGc+En*s0)RgRu|8Z9l#EyR8D2AF60G7Z zBuDu*5s7!c*bbRu+kqzyZAd6## za>4{ynMsqFnXdkmG*PdzQtdo>!i#ztAg#{w6nf>oPEqXg{+XB(U3b=z7!`&ah)0Y? zW1=0svmNTCqRq$9&3^SJ*9ZK1kGArZ$BQ^I>AR^{mx=@RC!}I;r`cjlqH$ww&hHMI zmT3n$-^&ZHOax4d0;e@ugS}l|(O) zTjP+_eE1`R*HV6oUumZN(T7|LcCU73ynNlMY59y9y6AY_umFrRdy<@XqZFOFvZ@b z)R1L@={tgH*s!5~7VacPFBC=P2eo^sq+%hSe!Q?}5`Wf>49sy=-K7oK>1N&8Y}!g~ z+v4k9$?IEby=m$#?YnnBZoY?NAIc)qbLZhz5EO1uOksLI@!9P!&wXcDw_+p=$sB0~ ze8Tya4m!`#pVXN}LF}0^mCxyIKiRORN~}<43uJ{)S3I5Npf8aDpDR`K+*bWA@hkML zLQ=now#BBVHawdy%;(}gdXF!tqmkyrILSh! zZrE9p-WBao3M)N!VYy#rF>K?v+AVpCDDzwz;q0r6I&%qE=kjx0*-D@BZ{8;pE6EW1Y}c~UVQ zfSPHD`ug5fd>0bb5l)WfAWO$?-t^)HX1*qb@nM0oHHb1IX$)dKdHL*XqJmv|Q`(L&AMNU$#3XCH*1QfR3c3qB1DBs9t{Ra>ig#@$_{| zzsIHHl+gu-jnyZB zl->)n*GS3^p^BFt3&ZJEob^6yG*TE9A#xanHS# zdnv+eS7!S3JejsCQK_e=Vv{wZd_MKsAL0y12G~U^WmR0->fFws1+G;W2}|}Rc}bp( zwB#Elsl(h z1!n??Lp9TLNuef+zwrbXu@mqy4MNSv({hwpib|p2%4bYz^hC17kaKh$y;0f$;ENoY zi^EWnv2GuSHN`!O-1p5H{CNfL`}Kc8Ji=U+HZzyeHb3~Y62=a;B+Wxj7gf#Katz4O z9*dNIZC(1a1lDBvJeufX44FE8!>lARbo-XWf-ZXKKsdk*yIznwhb~%te&T%Yiv4Aw+4FX4=dn_T1f!I0m_cRT6 zv&b>sIjMHl8IF0~M=hRlr!C<8?ppS?N7L-LGitn!+8l}sC<31t*<-6tA!*;}FW?zF6R`7PR>h6%n@jlR=)yFA zS)YW}(c+$~gC|P4d4o9O@@cx`J3S%SFHjn1nmvk^y(Ndi$v;N52V0(8vKZKPUxoV&CBt^%;IxhQO*Cy_PoH1Bk@#}`lBe(9_v2~u#q{~-`|qAS z(|SKvl5~CsZ%a3t+`0nfi$uIc_&mz)p3b{fX*Ij8e4|HB)i~5@y7;v0kZFj~gP0Ur zS_z768?Ie zWgmXCZz_Wl+)jqfBG7TgR)LH-D+DOhDvGYCJ#VH2>#$;K<+O5U6e%V?t0a zA9!gdpLI{Q!W!FL7kk_Y7>YuLvc_?58q-1APHPRE?h4Yqz7=yldPXMizfXd8TI?J_ z2BiP3EkqncnwrdN=oKOrZ`t}0{yc90OZ-IV_;U}AYEq=B!JPUn^FL-N+xTicQ(7?* zc-j8iZFV7}i5WQKL)0o4z)89aNgMIiU>Y3gNLa>P>fl^i1g)YtG6W2wCe1G%~?h_S5!^Zr=zkn#h1Y)V|Jv;zBhzE41{U`r+@d@|F zqc(cbadm^XHZX1M^-kx@EuL>R{{Te23j<&bKzUSVTLY}m&rTD66s9@HvSxZStUOAd zy5VjjABvHm56Qi{@`?#+Wo6|o+Ua^c(AD)+Bcs=2Q_hi&I2U_1=)A>ow$%iPiP=cb ze|kjo7Uq^Q9o%Qbnu0OXp9uz5#ukF7)0l}0N=ym4`{F5RBMs?7G!CJv+(RXN) zY#BCive|+DMx1=t;#q!58-$CedHY%O+0mLSI1*BX-3pa_gJe1M{>=Qcj}t314@s=U zBrb-_wX?u8{aGdt_%tLQUy?;oo!<|N@!y$DvazlsLjVI+_{C&$*|niC2r@)Mfrzd2 z%KG!%JHN!BPP$n*ng5T`f3wIX#_i~q&t}gjqgKE9)!HYp(FX8>KsrfC z)+1&0OwdlUP&J0ixt>0)p9<1!p>S`s%*4plZ$qF+uhg(sq0J9%0dw-GxHAgcGd|DKd#J?ckMKu_kMgc}2w9D$dPqn! zl~&shnhv#(!nXnd%LAG&*!?l1NSe>KKcTHhR81MUrkTye6!L#NtPg(HC_#@FHsE8>~WFya5pDJVoFjz7f3i{-FZ|Y1!#&;b(*?NuLsaCPSf>6eqH78NW~paNg@Rd z%uV@a`#T;!1zqFGW8Q|vLWcsMDi*CaQn@Zxq%p6Bsn zvTqbe-&$ae)^7Gw_f3zIgJJV;CFYidVdgr9i6jpXWOg-B&KcIpDK_IE&a)q7kanhZ z4b2!^N}r977k=)PhkxB36y2s1stPzvDT0o}OkAl#36f$@>qMVBe=h7FShTsW`?Gqu zg`SF@Fgfd(@&}bFpXMV;VaHCI=_$v`do^weZpqXbE$xMSwkAWa##o zrb@4VNsnR2n6W1Ov9eaQ5m5VNa1XhuI&o?` z-+qB{?8Hli%&;WIXs@QQTN#9k_?F2%=A$^hZ4E6uJz6V=0d$Dk$yGGda&gx` zS_)y z&icUBc4mW)lkN}Bf;i%&Q{CqFxx#imh^#wVl2&SiHY z0YOg3wF73Qa}5~p2ZYg_#DH@IKX{-JRS!oHo=Ue^%%|XtxwwonbJsTd8IoB^3hsGr zo}m$JH%aJ|Okn+i?xS`4RB;RA)WGgW`{`hLd-6TY z=__LBg*Z}+vrShHMun(&0O?H`E4zW7zPH7F4|RrP)F{KkqoNLkH_JiM>RIeE=DkU= zlf2XAXJp>#PBC@_K)5T&2l# z!?i_0DbswkHg@E1$q6a;>))ib07G^?&HkYsz^)`9McSP#>gX}?v0`htwCE;H|4V-B z72PrX%j*Qr&dgOgoHcK53Fsx>F|r`D(l5I{FCH)er-1UUx9kN9i)&mmC^ZuB+IoMS z>QIyqM+yeN$tK^9lJ7~i5?xc--++&s^gDfxq+q$Jf1nva8?dK2)_YPr_=>4*XxALW z%+TG%iH)u8X#!HA5KCtof0HN={yEo~V9v!nkj>dWzx%oh0_>j&E|FcY4HTnFR&)M7%cd0J)bWvL%SyRJA>?-8Z@{heFkqA5~j}7prDu zc@kqqOZ|aI4b<$G(!LC%6R`C62WonlXPSDj<17^w7&8C|NaDm!wzg3uVFQ5I?h0mh zQbj28q@F#x;!zYcoZG$ye!;y>n2WoI{?!b+CwhYs)_p@#D_PO+*xsQ2QID?ozh!S zu+42mSL8U^p7QX%OE`^%6F&9F z^FzaYrl!|+BIMloeS(tx^lc*kQ#-$D?A&&BSUroXf{Yg%D5-nS3bfffsdq>O@Y_zL zv`yk(y-^>+CQeI6#t3Oe7iy1g&04E!L5Bk&xP%vst^Y*S*izY%>zcT_g^~;l>RE>}W=`LcTRt6}6fLxri7OLEs(I6N`R$w|eq(GYh zL=+~aV4xEI(o#Um)Q!6)(hvS~SKw0!j6Pet%e{v0^m?^jW^P#Fn+<68`1Jx)RaH6U zG;&)1BWM(NW09ueM#wIG`$+9>PLDWd+;H)fAzEypzWur~!*UfavDOQx3e z124QkX*zIwbSq5%RJV`w48-^U8!j78$OZZAfBqkal>oE&|IT}n@}}c|CWsjt1GAG?L~!wKw+3IptMARJqfv?~)<*bUPX_CSpN92t`1D zzd%VlJRCMbW*7~^?w%{JhA|#P%AWl2G*6=!P;++uTV8EHy`W71M}_Y6$e8=P%`CgN zZC@hD=bC$E`JT`#*-cFddmvhY^KQxl3U&}dAilNw)*iSiL4`;6G!U2foakv_*5{eO zZ2)Xlx;DC4L81Ogk*Qko$-uz$I>pSuA0)4%!mA-aX!>=c{m2*g1r+&F(C41+!+F4F zjTY;<@W6rR#rkR?)BF-3XsZG;+-uEUI*fe)I3}RVu@~w>$u!mA@YZpr^})gVq{HAv zC~=NH;cPh1oIM=j8pr@NoC~VXK_b8s{heDtVX9Y~Nm^pAQ<*k2(Jzt^hxqo@(KZPJ zf_-|hpP6y`Einy=IeGor)EPMK0O-HYx$-I}%m%mjr8_TIKg-v#7QyvOt|1_=6QD_`GNv*NJHkJrD zT7K^*ooIYKdc?GM!$i?p?hB(AjGNwt7sW=Y;LEr*v~TjpwPG-|5lT3n*KoNTT}=(pM=EZM`F6@V;d8Eid#F|##W2UR`yi~`k+ zI^Of8D79dAnX;C`a$}E0@&OC9Pq!-?#hSO!6_dAlZ{}OVGQYv0ly+Ns!vF~J2T9fD zN*5EBv!ctts>gS<-Kjfn@iS$pOC8cTdz*HLjAgK*GxNANFIVYn61Ne3rx@p$hvTgL zMF=}t?GxdD_k?e#Jw+RM@cEGP-i5Ey`l2t_Q9xqLdisk!uhR=3A+qhA8-(#QK5%oS ztF+&iMQy08`IlEDI|#q6$uW>o(vyFCn`^jGEe-_iQ_En(JKa1QhoNuAJK{=vnuhE$TgHG{?P0D{%*?f!1#L zEyO5TeSC&1;bc(Pj<_K;AP zirx2n00|OFi@E548r?nI8fT_o_F)|HZ?4LdkymjS>Hx-7Ptwb%q1J#kn9OeJQIOam z-|OpUc2Qixfn=l4D60*85F2^s+e}lCuRYev%NSX$W)MLp`vg{>^F`<#kCU3rR*77w zVnSbav%?i*duS`ynNNei=-L${C!h>*U^M$6RPxA?@H*F#Pl~ui(8ob%I(Crq7kZ`B zp&CTxC~s>&z)C2B6aa!6HE{4Ju9q9h0!+EB6Tz`GL$-82z+rkri6qzvsRZKPO-$gFM%t#n#&9q|_#3r8G#6EupEfPK zCdAymDzMhkkPMO zETN2eat7IHgGwXlq^*_V@hBHeLs%RE)Ku-eu2U8S6H=Da}{S^>&e%dvo|S^ z$AG^$6VRe?>RYoo+IHF-We&4T-303@DT>8nr0lToI$yQdS=kkTdkVa z6iovzGBTh^P~)7<1;j3VHhF^hu|ixAv@%i;`mUA(wcGAWai1i@Ye=0sQ1H=E*ZQft z#%Nr9^Gx8>4{jg!t6bb!EAPhZ{^?yuoFu6P4qdChNE=w{8j5Mvtu&zc)*OEbebO_hnDASPe!{S6!UZ(2h|9!f^F)>oN4 zHb*1|>0%L8UV|z#bg{dC(&Q{gPBg+6EL@){f*i}!QK1k%5FTC^R>Lj}DdlCcjnhs{ z_E$+tolCepoSutSKgLD2;q3qUXuzU1^x+(^G9ICd>bq$I4lL?)N`s=(Ow0a{ALkM> zZOk<3A8f$EDXQzHjy_ET_)JsqWlX0cgfm;I*dw6^@TYn`SYE{s6Ff06D%buD*fT)y zp%SoX()>l3t+NR8?e0SF_ioOxZ*I7Ur`HNYNoTV&!EH8VG!3YZ?vAZp6i*9vZoD8| zwGlO~QD#Oxa;kzEjgi{O(+M-5I+j7dgs}|J^UXkh(Qe3Al;ibm2+ZFn;(H#ML$5ha zi#XzxOZ6sBLKRng9M$bd-o;cQ14TLURzpTcbWq-OjsOe_GtDN%@LYmvaotDysDc7m zI&ZA7QEa`PqUKzD`f!)<&DoP@C5Car&pBT{>2S46VvW`TVqRsa60^1_KWB{|Q!n`B zJJXuts+6_5GSejeBD)<{^86Jx#{OOY)%9=}5%Qw9REMj5HbYy8xD}`+$nbo-jNzEHzs06x>*0}dE%T) zH_@VvNi8x-9CoOY{1yL;;1W^kwtnb>a^0%t1^NJlTW?WaUDP{b)@QYYzC}%09t5#| zxx`ebM+fnOrAA$SlWx`*@^oIafhcaul~fVS+B-m%^@TWEu@>p>EGLLp?ZZ^p(3ze~emP9X(odD7d`}inRypOh`7@Nu1IodtYfy%M5dd&z<2o}g2mr#_FbBY7oSk-#2Byr|mE#BI}bQwxV%!Vy&OLsMXZ0H)soBtyu zn^aLla-C8HUtbs_B`fLS*j z4{6$B%lJy|ve}z=`5{VICNGj?bV#4kb`_x^jb!GNOM;N*{Z^`x)$wK5 z;jgZlqVXp-GF5%La0GgeY_ig$$;k{rZ5MC~;&hv=PP0GN#%#1#y!$dX^BoZPkzQVQ z!Pq}i_BYs!YD+L`LUV=RT%#6a6Y&MQ5~Hqv6@}`)*GUBGD^@4@$l4=~-J21Cx0}ze zA*YL4P&X!PdD^O*@O8KZrHi>-fv@4S6(rfOr$EHS?{G()r|EvD8?(>DUHSf_Glj{z ztzbt%?eP~ahc8yT7)@IGMM+hljV3M)=WwG~K{3t>TmZCBoIY5?ShOL#EX22O+iY$4 zXRU>P`aED25ghRzzkCbUMH1hh`#cA zE&$DqD=|V<)f%AAcMc!G;5c@!oRrPz-8`aKUew0mTUE_YPglcI$dTzkTC)7s zOR6?S$MxSmG`*i?ofP`w!gr_R&jZ5k$Qi_CLD{`%A!??J!1 z3}1w$u*ufQZl_nuaN*(nM=`ShPH_<9@nexSZyWp(xId+KfYN#_!lJCqUFa5RXgOyPcx`{PcpV5C%dVWyAF4 z|E7dVwG#mQI#HHm0V*5muqQk9aTPz?UB+D1zkCWQ^9xwMXwYXvKZw!b1_TxdZd zs>*pmVF87>Z?$TgAYi&1+rO=Gg6mzNk{1LUCRWzo3X9KHKYw(%u20EGTtaUb?eBHBby)B@M{{>#}euX3E^9U26Ozf&;qWPMb$v|`16 zejy_zC;vY>+u(*nU>yJKb#AN$9wDIzO`!PULZ|aWtzdUo_6FV7UMV|aeo zn+MYjUo3sP_x~p!jD&>5v(0M@QEAzE&Bzw`DhH3~P9IcmNUai1VOCLGcrFFX|8{r= zA_N@9`X;Z7ZvmQf=7sOR|%PwSbi$yPo_xl$~l|lx3~3>64wyKAsaP6e*BoW zd_yVVT9@%=d%I6nwy3CR;Ct{POZ0W(87W2#+jH>@+57|-XFl8kC=h-X*Qm_y57IMv*yR{{KW`Pp!_30JBRsIhR!#~B zrb8Kf^7G@Iy@P)$Neq?>0cC6C$>HI@%}cP9-9?e=#@X`5@2@HZ8;o(>B`5!fp>H<; zL3iqO5dIHDAY3q}W6r^WdGo)1JBJH!gsP9T>M()fU-Lq62PA3Rb#ySP4ZntDj6nLy z4kZcshYH;5kHA<+(K}4-mtU_ttQJh?x6L5LZH$xPm$6{00AtwNn*UTM?gXYRXXdW267UnYiK14hP?7K|UxsTuP%OJZQj1j2H$^AY=R}CiTx##&VCiM9)yZ%2D z_uC8mpNaeX34g)f`v1(1lk&D}llc2S&BpGn%Zk?()#&+I9VrtUmcY*FQ5>dH_m!N9 zH&-=h>+^G9!s@{m!LEbYQDAJXrwH6@w+Ex8x3;2F9HMT*tA232=wOU&$NjM&I* zn6ZPO@f+AYGhg$9g07zST8u2ll%4X5Ya)wGIW`T`-1FWN*!EEMh%=`JeU3yPMsfxj zzslj(JWzvJJuzqRj)p;`>Db%OIqqW`m-xpa(65CPbg@yijrsQZUpm6bkDRoJ)q9)O zI;{s|w0RFd(^M{&zZryg<)1Ef9#@ea^fb9$Q}*Tl8O3QN-)(lJ8yC^~!4&=^JwW+QsxrFc&SQAlJ?2SQYrJkWbQ<}cl?lExA(DIrY#D1fF&U*OZ&)q@~#4Eqtm z0x`B|HXe5OmEn!iOA*M>n%^KH|iwr1N>U|E?(deQh7*kY{bHn?kz(VcN9oTr}W{=@D zsLjN*VMJ|yPYfA!aix3Mb>_IcavgaYR;-RL8C6+qjp{1CZK4P3&+^sXty|$z@*7?l zr(xZo-)H9n@6;ZtgaDYvfB710VZlqDLt6C4ood%Lik=D%9TW|BoX;CTN#7W{PYCk& zh%I+^uBz-BreNiMbs=%}*=1`e(w1vj?{})a$82&fCAjm;V&}s5%#{6bAWA#W-C`u$ z-FwN>EnpImKaR#OgDoVLvdX%Nol5#G z1A+XX9zP#ILZXS4?Q&2EzXThscm_JTha_?x*9bW1=-6P=-`^f;UN7+afCY=#i*lEF z`A5#Ue-&$Ae*g~;JwAOyNWhqHgBx_Fq0Q%quV026OHiSemY5O2Yc~6xQc?P6_dvr7 z<=8LZJA|rpi2h^<{oto;A&tEzyK2?6fRYo%yW2DZ4fZW;+IBTvOgTbz5ef(Xj5;h@ zSX87o?P5tz@tG-ClxQ_;(ID85bj4z4rZBd-{Xx2GO|MfyA~Qf;(jFZ&zPK@k6qne4 zF(sBxj0a(9CQ?U7L~Up`*V44HKfnGqmqREHDqb;obLLdQlE<{^y~aJUcb`~Wbg-Qk zLV7ajQA@%K-A6X=W1%9172 z8|5LL>D8`f&oSFafNm0VF=WBfA;vsHTM>*0S6DPfy5H-z6zD|NU$qn|cjci7Wg|(c zVb2=dG2L;moR}`vUl{F%dPuMyiz$uz1w0y5X!ZU&z9?Jiraaxfr)l?i@n%9NnR=m{ z5YxH*3eEbUS7o<$Pui7(4YQkEIo7C~STMIu;glL?e#ueMnigyCUe(6g$zHGg)ANiQ z3tp~uh8+HoN4=3+ynfyY#w62to}0Kx*<~AQv09F0rJ|*QTk7w410JstU=1WghY?Zl z?v|^E3&^tgx$ZTuhP50dzu;=OJCiC9Ys^>QL|a+2*7->XMb zjWOi8Yt2e)+?YA-px*Yr)M=r@-Q&T0Ho+B8{DpV3KoyqjX!xG;M|9SaI$iZf1F5~$ zl*8FG*+qz|hnkM5Z@Z?r&B59kHrDmaA67@%ZLP+ZIQ24_XR^<(?`v?AP1zJ#jh5GW$tdIcZG!TIS~+^~U#Ns*%-yY%yqDmj0ML!`Ojn z`+S}eyRYD!>OAH#-kqieIdl;wk#;V!Ru1#=9dVgFu~+Yo=iXZu`%<06l3ppx?!c>C zUlN^NTLwRq8uTrzOhN7wMZfNA{=)5Fl412?Ze!}&C{ z5M{vRvGb6J+k1Pup2yS^jgCy2TJ$wor16!*zt{DEUlw24Got(K@kuY4MsKy6{<^k- z^}_TM;j?)E0z}Qro?hI~erg}%YZ*jjUma(xmb;C# z&}){4^}fv5IeiF8?q1NxFIF&1?(c2<@FS&~!jVi_%34lhv0AJeOtga{tegjC;}Fux zw@V5?i_v_{N$C{&4oRynGLH{eo1UG0rFGtM&u-pUOOs_z3hu4aD z1xIu2v_<(xQu%1}C4))7qgv;!QiPZ4+C!p3iy*UVS*ES{*9B&AfBLFs|F}eyIjQXU zAnKj1LRNI%C0CUjTN4Rb#9wR9w$&&q?sxg_8{&Qx=QJ%IT$A- z_lxiJ&8q`z`77H4J;km1j7HB}lLRJDu@8`?hKQZWDO>-Wwro6^-D@&#*3v7E`u9rX z2PinqJKTeWiXX}_GFb1cGt4&6vFh#?#u_PerMCVI-_y-h_7*H7=HYu6{Xk&BZ;qM;bR~uZ^$H`qQd8+UK)%24uAb zXvBWn zD70*&AvOuA53p{ezw2#R~CqP&o; z56_bG9s2iRg)$;lI_T<{a-8CAkuYwC1wxIvqEo$p& zu&d8tc>a}QUAHmPUycQ}|Kd338BtZ64Yz8m`LS-uL)$t){gR1d^zY%&s7y{3_8^|0 z)i<@3m*w51r$2@gem)?)ZKlra%Oq?sRDa{|U7()QVKFk4YTi~{93fhH-m`m2*y+dc z96kR*LElaW!Ov%}Dja783<9=aTvL1SDD|FqOmms*dcLlOS*h-hQ@WkapY)0(8As2f z`4~6p^6vnaSz-Ad|*&B2QS5=$1Au2j6o<$AMq6Z8GP< z_ZoKSVi3yB2G^v;0bgx9`57Lvdvjt(ZKI|o4rz49f#9vFGE$qBglX+eV*{De>$R`+ z6g;_rF0Y&AVVUjuLNq^Htl?@=7*c~=xs)t1O?I--OttJlpqBe)sy95~wl_}*MRV3c zRtt02Ao-BjXux`2D>QoV9lqOdZC4fBap)e>u~(49oUJFCG>;;6Jwq5I%!o{d$aRxx zBGo)l9c87hXKOg@@Gro6acloCZhnlV;O>l)AaFl+{=l+PIX=Q%HP|5ioL*ze1)Sy>&+l7s~jX5|L5x>3-*L@S>vfN z-6VHB9ZHWqza&bFhBtkb#5Z(IX;Pvzpd83Ok{!JBPquG&JU@0DeI<4#kD2_O9{ok6 zcu5J*g?0^DB)!V!eM@HiBrloxICjG%wwU?T8VB#^%#Ol8lI5BEUYPS&RkdAT4Um0` zP373sLmTFMeDrW@p8ijgeb3zkx)LkpH3P?w_nYfJEUygC#L#mzp!vxKU;FFaQwt9Gd1eZ22$IQ2nLSI>ANPx7NuQA`XLDdh1F`EpEbe3YM`eks^L=IRpJD30kVozGP1YE5J~gCf{T9IFXZs7r5qKJ*c#ZxEI(c+LSKjZI`UcAc2Lxj<#K z13`JndA=2JmJDfW&WlZciPyX;Hu-5j(jC&>-Q96F-|stToDaNz-hb{mV=x@xfcJUc{qAS4x#pT{&4r_sx&98$XwGlW z!*+e4rMM^Y){h13+B&2vIZtDC=X z4Q+1zLR~SZ5NhVupZVBNwyxP=C!-d`LH?~hA!N6FXtSr})0Q#fSPep%kg7RJ3~@~X zyQwT&S)snn>CokeI__Mnuj1P~=17{Hm`!!%xQJ$y+5FQXrI#O1&p^aJVQ0_blxf5F ztyAk&*ZKVJ2T*t+IkH5mXlcgTwF9ebh z^#HP~h8_}1?w*f6C2SwFPKS!0-?}xX?td7CK@5{B$GaAa82{qJvA_Ri;kbO|&PV1+ z-9ZO@Bl1pR55R(%>5bR5VWPq(9t0Dt)=(2W@jNsg<|`*?s5z;e_CW+Lu$_@R%tmH~ z+}{xFm=xsJ(Zo}-eoFT3PX7)X8c1pS_HcM_n_QlzXM|T{Z|xnLpKY;{IzE zz=w5QuAbXnZaayBC3lMcaIWYm$K9j!CKMeJmNZmcx3=vhou}@IuoYt+M@%Y-VFtO1 zRizp0{**q%BBoC<_#Q4(AJG#T=y14B@|-JPoaiJDfnONIvgmP2C-FLi7P28!vss4| zztYBQ6dlG*?VMC=@MJnmx|&UqHde)cd?V9zfen`I-5XEdE zBgSBP5kO!dO`%SMFP_FjQMeKL&mq(+Q<*~`H1G3^#f_$?>3gs!%w&D&WAZr4>z6Y@B6{s4~cB1gP z{gMus?gmaqT@ZGjm|+f|Wjlx3e{l-Gt5x?C->|*-cv|69UpVj^qdj@>6M-O(gIwNw z=Rso^mEwJ4S!kl;I$D)aALrhFG(7RxQrq5A8&WT(++!WrcFymHGcNd?Seu2KvUGKI zDFYI)sQ1CF%YK`3OMiCawFDXOTJjJ-F=Dju{>7C01KE?tPuA^Ja_;9=)T_J$be{t1 zlIx~Rb>FzYiOy<9JR;xn)T?7hhRduji?!@xtkN)9mLRvt#=Eh1mUZ-_n#A19^DazF zzxqBEucINIkMhwy^YD;Y;WtOGV&;#9CG&3*X`#wFzJa8MShMu0rswPmL5(olD=%GUR{9R~M(Kq0-(QMsmUW{_S;t~gaz1X(0^tJ(SBS5NtY*<>qecS&s< z_rD zTO1s?XI&I%OT_@X;_!qNQ;1;S~-o6P_y#G+UizMgBN262Q9< z3ovAj?Nhmx?9{t5fVfPflQ(EsRn*d3n$njO8NC|M!Bdeox8W#i5D|R38A*z_LCj8S$5lAE;1(RA*|ff3U!>Z z&tErgow6=o+4fSjmXKE9x@!hF*(1Z?e)u}qXPg+~#}pgBG<{YUi1KIOe0QsDxi=!l zd+)~qe+jNbLx=>7Ne*{cz7W}2`FXue+H`3X3pSJ;~MY zcrTs*nRU%Dk90$-_zA~P)K^5ghK1}tUCU1bJDI276DS^)YlSM4CXcrFSllvZMsy)Z zVRMk$a2ra_KSS@!u%6oHu~Ekx`A)QIlZ`rXNP!IV(a>D|jX66oQEp^FVb7FK5*62% zAs`vf(mZ{iXX&NTii3VtF5yG45Z*{xgeEWc`-TX*`;hUL&#v9myt^%x0x=vG!t1qy z%F5?MY4p{TB_mGh*HgCJz6o+; zhxmmBLi8ikV=KNVWLmNE1&fQ0F3({?c$-Lm>rTOZ-mA2J_(=Wpg!#)u9*YLbPrJPS z&$sd9UqAl#g2vxC{)NG?X)f9!WrF|#E7mswm+$?E(*v{~FY<3AQ%#d}GPoBg9}MH4 zviL|4Lj3ijWGFvEmRmFz;vRa-{$n-!+XLz?F`K1)CGlVX8pVZaM|1SPtcHjSQz5kydJp8PuV=W{P4WPks>HT}gnZWv}yyGOEkY)H}6(@9R z)8;J6-*_+?$u1rI0jC-yi4BQUDWd=4Hd@$E=)jqWXVbhGfu#(&VB?sev=&-bx**Y; z`xEjtBwE-@n7=UY%ZrgnZ1&v4L(r^Yq{w248pL|^q05^Xx+szO&yriZ*dT9_15Bk+ z0=8Ln#UP3IRlu2etYS{79r*B2`zi8-z|^nbU!w|{oyJTYS(_GGlsWyalYB7?OvrB? zaM%s%RA#x5JUlG@5j<)>3+V2_L|(0%{~8N*cY+oDv(R2oyibe_u0_1VJQw-!TGP5^BZszd?!qHoA{FW+Iii4}KBFp#{oPZYqz1P%L}^ zYXdoT^_X~n{C)w)&B(R=A1)vvndOnaq4VZ)=$Q`X{RXK7_q#0ZefDVWVo)pH7>=LG zlREckM-+%a5+iMaTWDpMlNNoH#*tbs)r-g-ruAu&DTCjf=LEYhp@K{U`C-iFl!IhZ zbR$+lxg~u0>|g68OZwZL`Daid&cmY6`~kjYJpbTQQK$RsW z*!*tYKqi?qT5`^O33w5d`{)Z}HCCzrXr-Vb&-nRY$cs2Z`(ukk%De@?D6`&nRQBM~ zNg9w*A%Y|uoi`}^y<+&j_iq~s+wb9H%gaxKWIabjo|2!uMGAr9&vSo^!Cc9b|8al+ zeY-G$U>`x1X=+eCT$C1E55a)fQ?HyE*-&9PF~Q}yFt2(lh#&5Wf4v_MKhlun6oYxF z|9;{B`3()!?rdGo9x9nM)$z48UvoT^^cP$)7?-D2E)Q1&-(#F)pm^md><_J35ckex z+2GfM?s*a|qp&V@P3_>(Md5GQ`dmQJE%4_JmU7}(^xlH8^G!;e#m!LMWYn=-Yu)~| zxvO3lt)t2$vh*-qF_|vuBd!Na2@a{Bg7ce^l-5G7pFWM{YmGGT&eW_~f7{d;E6}b; zas?eqzYx9-xivO6I(<_v@ToUR;GRA$tkg%xb`dk5sU|tNnPrALkV^|eqaD8h1OFc` zN+ycor3(YO!Y|9p6|?E{f?V|q;W6bbIVmvI3nyQuXpy98L@1L`adUWaXD82(7)+#L z7f7zGx9aU`Z&x;-uB0)l8|1di-Jz8v=#=)-dvpa4vZUW81$GA=A6oVaqC$p$>QQbC zrsuS~AL%J3qtiaJ3u#}+(#NQjZ{pmwLLh^ETTLrV<$ zIfp+XX_Z;bX{pxOmnCp~K22G*#&enf?bb-f4gb+2Qzkj^ghZp(k`HKL$;bUf^u~k1 z1cS}B7Jb9TR1F(j)%T)-G<2tA57z?)-@_0|rQRhLxFf3+>-FNJQ$|H4u+d9+Ttgh5 z{D4@{k7c#;j3C!kaEE*vTvfV+3zP5aDkss2;*(~dm+c8P5VHpcx_^t}9n4Wiya@th z7L4e!r#LLc7x*2a0!k0SoSevDZWm?q+1ia_Uw?D_xaID6fy3oKQZ8rvvvIT8>4|C_ z*K)I|H~IZhz46N%!ZKn}JsTv-UmBKs!!>8u53_T%>g4LL%p=_9zT-@(EfIcVvfqAv zlW=`uy;(2d=aTBEr+D2RMNS$^pApVpmSaTM7e@u_(-qlmtpCBFA|wRqLT^(>?$-zN zIy@#5h0*@b`FLT_Pc(m^xwACWEd;wZvb8r;6KyKD5V12=5w$(RQ2qI9g|lyYE0z;? z%)=v5bl!?=e@xrGgSO#1&3bFv(s4wm{d*UN_VMsye+6C(6b8Clsoc>oQ_VoK+*QWEa-|5M8qEb7!9^NKKR&R7fa0 z>;=7H(oo9PplP7hBAneAswu6RJbkmJZ$T&b)q5n~a)z%5QsmHm!d5SKLpR7`0)yGbNMHlrG{C|83VBrwAth;9NL& z!geNd1WPyzvVidd1D)kxqu*`}uH-;ycSUQvEy(#{`I}(f&ESJo3kyJXk7|=jLL6(C zf5zIhW%{S-f?od^y2|CEIMe(eyt`l(W$S9t){HEla&e@9q4WxCTzoYT2D$>sHabL9)E zl|h6?NJ6;@>lMA@7^`CAk>?lo9nhW04PSOE?7Hm`5;^|=?R#8ugBY? zh`5j#b7&A4g^ZX~G^<1tzo822mL$r+=B0Ll-k?#rg>sth2tX99Y{gbtY zW4U$mm-|)Q^>w70L> zv%?Jkyj;*J9O+`%_-NW0)&MoDQi#a2NzfZtQJUerwexu;w=32gNf=tX!$dJx#VUu3 z!l$2Sk22ZiQdOb4O%>0HxE0fw`w>tOofji|LXavFthJz3V;ftnq^S^Qp>$2p%2gr*=r31`Kn8yl}27$23o=JsQ@5hZlD|I2%3qQFF+&oshEaeP3b&LrgF zZ_pspJS8sKR6ifiVDM#xh2oj^+{?q+D?FielY~=HddjT4c=wkRJAl{Y`k9?!>fuY{ln2#OZ6qzSc-XSnn@pKv%3BVqZO9278=0_vG@BZw#!W_7CE;Ht z6lc~<6O}bFl(a!pMHPI{TWq02N*i8Pp?y=LUp6OP^%A)&TqwFBlkI&piv>3(lT$9b zvBT!D7$%*IKcm@Usc!!&87n#>LC|jJC{bL$Rx+0e!d0G57g`v03AzUKL!=V&dr27I zE<46Yx5|b$1{K9L`0nFRe2B6c`DcWP(kjgd$2R)?ws>zgxDE-^MMIl(@SN)ost?BL z0wX&rmd#l|_Nsun^ip)wC)s{Q3+#@+3T+MtuqhQ2+3QHv%XeQb3-ULa=|E3iEd{q8 zDvgb$^W99c`$Zak?P4mg=a6+&vb_Xh4}tph5ncvi&w=m@YOj`4R$WixS$qP!tL*Ld z7T(5z80{7sy#s~a#$jOOH5)}YXJ#0mGm67I=KXOxlfa-@PUlk5Yg|t^T49`V9}Y^iT}FgK@U7;jf?M z&DEW}SZU2h|7@M}u`~aVUrKqtE(fjA#a2TI)qTZOPgrkWVV=Gx59?{1)`FZ>DRrkKdB=A-WoqbB0@d~n>+>w4%;yE#P6VAol7QNyD!YLh=mak zmKrhS;GX>{`q*3#KZYar%P&WR=bgV2XrG54YuEBf5Fqtng(84fuvafN^q|tbtf&)@ zW-#a@1miD8S3qA*$H9^ZjqAMLiPM@ijh%G$J)oJ5PU5P86Xg7CGQI5RL54{swz$6Y z^-4p!Dl=p`B5|JmVfG4`wck%YAfLeoioF4|u0yEwwinoN~@^&~P#yrM~<6$!@uLzXnro-UoJHh+MIjcra~;BAmW)kxn@yI6Lp-gITJ8+r%4gMgTWY|fLWWuRsM9VLvP!MR zC@-|JnaEuy8GD$sa^M}p3_5(ch)UL|(4g7u8Mf$^hfb$$q->>sFd7ZUP`C;iDv#yw zbs(aWs-B#_qlMOE(C-WXqtj-D+_tPH9?zWD_q5FNPqJK=96Hyi#0X{y4-bVN1A_9< zoW_nBa-@{@)npOgkU>vm@k-a$N2Cz@-RT8FLMGSx*q#KYIq_PYKi3(vPTzN~^d}Q6 zrP3B(X+FhBETqWyco@(ZJ-%E*#CP0|b6p{9%t`pbF573xtT&{V<09N(stnAnlx-LE z6$`-JgKwpLHG-PYJ7~+&hutxRIjts%!D3|bh z{!O=US_tS|^4a;-8au7Qq{*-!M{8w<-X|DJFLk>9xbBe4SB|yxa|ui>`f((S|0TKn zLmD#ME|TP0UUVzyY)Sm|M+*Ob&{gnPf7W%?;?Q@$MqsL}OID1|$($!}W#jUtYoCCrqjN&?rjSCYPdoNGXQ_@0aVd zId0Vwr%3J__%E|AM`m=HXANPeQkn`O;awl*E?&AOV|y4=Gu`Gw^Mp_*3HV!F2a6?{U`9JlCu5 zMcnR^r1x?Dno%!|`(XQcL;ez~!g_^LbfS1A-Ex6YX>li}=Y}=NO1#(%ur%}y;w zl4gPAh^w)#Gfj*~T)3h1vczI;7OKLHhV>Tuorv6Z(OUT9)Eea-gac7y4--LM7noGG z3_zq)XNAR_w5WtT)mY%(`y8cSVnIzTW+!1S$Mh1Nxp4k4oeE#L$IKVVsvS_Ec}3%R zEWW#qP^n(y+;qJ(vpGJswED|u0N(vP_C3Q>f}sKP2qJtD04&;RmOPmbTAMvM1Cs^q z&vs`-ZtWe~QUqSCOjnf@9fBm?ymOQ~UhiZx5(Ukcd(uX$#$F1~Qc?RQm}eY?u`WO@v6XelLFOS9GL1D(=BF zE23Bi=Zx<0WVbkCM{9$jWH9ozvUI|N`|eskXAXqv9jRi|36w0&oNV2$80kQ?K1#M? zgRsH$z>b=kYFNS8^*2!WN$ZC0G)Pat483fL@?1ALeD^Qc^e35?L-&!1KK<)vKbR+? z+jgwIGGVYOq12iu9%&uM7G9x{tD-iVr;ci#o8^zd!0_DUX${29RTs#UPZ$nYdI^Ao zBbvWET~U!v`#KvKIGg2%h;#TYf=X6&PiKO@l~JcK;H?;2)9~fl7Eu-C^+_f`P;p<9nLv-4njS9S4fTuk5tFGAK4aF`O+07TKz+D^oKPNuUE9rP+Z<`` zwCEHeQ|a6(ci$(SSdR@p@VQ5i6*sVV2R>;DQPwh|-S4uhAQlVr4-PIkz3z-4FTrMe zLy7>bXhqa0k)LS2MOH1)s!KO0ptWTXjPZPCZDc{_ewS-K|KUfaE4m$)w4ax91juU+ z+1Uxm<b7wc*d{`7N@*OzZA#Hn?ZXI!kS?WHYml@^iN}RyAnUYCVibDZfm{ z`XE1&5t6iDhHNW>!|!0aC<>^MdvIZU=qXUB^bWsS+y?w*%@u|y>a zT+YIA6%MjW`6~GrG36#>d#YOucCuA&Aeo2!gD_fcr+$(o&xz2ibyKJ70}k$XV?V>O zYC9WXB$uv~=uA)t?B*`FsIp{Jqs%Fk7#pxz3+L+MQRcHZE~Esy26mV$4(!uKiaLI&_@Om}qq8NJP@izCSL_@HAm_vwZ!T(i!HTg*E##}(Zn`Fq6C7L2!L#qKYP6g2C)C!l(Lg%Zo~In)jov#E*ua$ggpAR*?R zjr%)6p8d`Qwy&iwMP)AMvcazdM0bz)|COIl7I**)8H!lW3k-6VcM9*CwZ36;HR*}Y z)hDYH%K5-X>ds!bTCeZJTrBnLdF~;-B(N^{ysHaihw7G)iy0dL(*3t^7&qi#T5F%# zInEmpIH6*qIe` zj{U~UiyEqJN!JP;XvFDJx!~ zU2983teihxJv%kUp*!a;()7*w=7c5B3OHGmO-BCG8=(v)&Z#$>LQuL1=8-Pi>}3Z& zhI)B;{hW52p1UP>Q@AGBl_$G1w9_giX)3PlpWT{`gUK0k$#vQ!M-#f5$(CCJjz(+s zd;ip(?r{8$Lv{A~T+&};at4u8CplrQGpHcG2$44MHK0` zypb45wARao;MTeqn*D6608bD`Bw{FyXYzx><@kVXbf=$3ro1Z&mc?QgZRo|G8E4$K zD;zonl~jD~j~p7!YT~JKKg5p$&^Vl)(`Ne>&8^+t63SCHoUIYDuD1Yjc30lr{A#}4 zK0#E!oxcedf^nl6!jFL0(k(n%>r*uY!^9(|DlE3Hg1NUp*5!=wp;w=+8_f`+(GsW) zdRH3e$3BF8AmhMAhw%2qiy^Q?I> z5K82immto1SpUgKZdagbrunLKT*7S1gaGEOM86-2h*&E5^y{u?t$;l(LCyP-6}z$J z5q;^alfm>tgNZGvi{BoF-uYRmX|G-nnagaNZT6IjF8ovz^?dS_ZUxu@ zA-4APm~eXcxSDDzt|zo-6=Lk_t9+oQXz8t+sD9DFb9b$|X3Nii1WXFm&0tcHZhKLi zva5vu1|0>VKt^1lFCE4BiSM^R4Czbib(e8g$uC1yxVoK@XMuFY)4tFUGSL$y`W;^& zwBee1SNoGwYZy(>OCxi!(D};kz|cIUFb5=-p6N=bbQpNblCg>Iwk;StqoMQ?ma2$w zQkZa*L8o*(qh|P8b3%HDt=G++wus$5+|lIit4x3vl1=LKt`cfP2#?)^Zz1Ife#`SV ziNkltjg!_p_;F5!4?%|X9ws+W&w)L*ml0Rq6Y-@dG?nshq18UNTxYhOtHK?=cfMVG zx#!Ya=#^pjs+6YzJ_fQR$&)eVqxSkfV8J6cTYDiXAC%8#345$vPWq3@=QbHPdRZ!OblU`V4al3L4^}k`+<%mAmWjQ?qs43mK#IC{N zfG{V4Lk2g;6qbXiqt)B8+gH}1;6Q9rC^c_S0x4oPNzYwl)+D=y~q)?F5rQ~o0 zSnl2=I>-=o2Gle$JuJIDfoZ{laJW3b@*`~#rZ;n@AuRcI&zL9G+EXaz=GJV_)=2;l z1;nA5YQn1HDGR$$z?XC&@&utm3)(klnUk^%Ms%V+BP(fCqeLWgI2iBcgtH?Oep$w% zx}wUE+5NlVa$^mL2iI`KnL@WaYKHxr>1vQs)LKy89+=pvdzuhi4aE4 zQ2Xc=?PPpzF-&AB1-23>z!t(USU;vj7Neh7j~{PLY12ZI6)~B<7XbsxbDlk!mbtRa zuAR!uX%{+GcKjxZkPyvLo7PH9^pe-X__WC;N<@6B90#iE+jOr3Ng}5sKUu1sQmLC!az?Ed`ax`~M+qtG^)w2c=SoTub;wyT%8`P?FxPDdDa6v|7*=oIq| z+NxRX%RB$*eZy){fT;gGi3cQ9Q^@&`yQT1FVdh1+!?c_6pN!r#fXtF0r$Vzn$>;d-{v!SgkExj$O|n%=?aBRMOp3L+M}X@nq21T3UXBD>569du1| zr_Dt1Wfs&VW}0EBOYxH1xEe^~HN26|$_Tu30N!5Q3Rk1olg#v~;P|||ZcZfZW&lI; z%O75Ux4*p(d4IY)>9S222dafYKqOFXfXncMd*^zz5I%X=~4Xw?caHfi)dYti=6JC+j}Ok5%0rob=+u zV{z#yR-Da$(|v>NSz)zgZv?=#=CcFhcl$A~s^3F28ZWjax4I%+#U13g;T!hEH>li4 zUy`P_4NDA9Pg{)Ro8_VYdtAG~zte-Q?Q)RLnRm#xfVm&!`5E=)9+UO(8Sb-RG4u?3 zAM^F$D<`-1wPcyDSe+-hXNTi+jB?Z~lBl$cq>+9?H(wE@4ZQo(jxxD5W?|^7GB!5y z8J;kFh&uE#%!k!AR0Ho}*3$9};zVa?Wo-3-Xh+G8=Va9p{cc}Ut?wPGS$VMXaRb0; zGL{=PL=j;9=Vz;})(?L{{3|%Z1f#3dyv-6RIF4J@>vX4YabbY=cdk&O#N%=jOg^?> z?)cb-PICnLI&S2O)H7wdvF+<)E@lu1cuDd7;1m z&T%WxoV^EO!OTN^-1CcEy^D}o<|uk>D|1Pm^O z^K)NlA0d)|_N_S-yPcENeo8A1kYN;chnGA)=tF*^iag0|hsR+SZ>V5vyAAN#_pW{C zlSZbSdJ*?{_ZJB~@k^eo`79%|>UQnC%}H=-Kxfj{=pBIC?iLkaYBVH{Vdug{{RuMI z36apvdb_;kwt~!ltbO9~$t(I>7RuM~r>ESrZ+CdqPml2EPZYOgMDES^Q?)W4dz zcFgx%QYbJNF{N&F zAWCV{DraXc{4i>ikD&1>C%Of<hjR)kKp2PweS^-B_LJVoyoh>>y8rLQBx$5Z?lz4v()MgyeUeRFl@Z-}= zvlbY9Ph%D7 zd+|_o29$-dPih^v7hzfm6Z-5=dtxoSeczYZtRX~SVzHQafEin*_(x}9=wV#WF>igo zg9&R<1*x2Lz;MANjm{f!C@snGn~4vG_) zkm$q_*7CpIoGdHRsIjNkz;nQ~+a6b1?MX~eViG_h5!XHOcsE+>h&oYgl&WX|yH5`Z zR|puOY(|sWLBjC>8Nb?N3jVXw+D>pSz9p=P|jNgQgCi=?jJbJV)=9HGU{D z`%Ahc^xlpK!v%BiU(sC3`@Y0PHg81=4GD5?>DY^4>1&|ZS>+^`{h7A=`FI@}lgYTU zI|cwrlRZ2K2)&MZ0KS4F?Y1h`f$4EW+STsdUGiy~2R=HwSElseIUs)}!4ww51 zQ6%qG;0@)6kQ~But3ev`;yK^uNnawBYzCi~@My6T6(V|sLT)Pp=#zo<0rk1bznD zWAVgdC>wL`A*R!%0`|L;v^Besru#KTthGkA(O?yPt)_y@z`*j zsb_ayh&3jEV4TlR#-3`larNL_mAE%a(Vq3Ewy_uwG~@KOp5tf-5&481wgr|gJGd>*{r>NZTMWAR%6#% zK$Qi5cnfHHE{7{$QLD!;AZxvFXBap%e*=ITuzwlZygDB0?`ofzjSeO9RkO=@(zxk| z*S0YgmM(5m)BRCAeehVg zxrf``tBBs|Yj0RaD(+8a_dCskZumFcX4RD%3w6NQ)R_GJj}L}**7g+Y`Lzgl0)#D~bL=JN59ZmEjKI1nV6!&GJ9|>WsBA<8T z3w=aC0f(^8_~BF$S_wh=CCi)jC7IkQkQ$jr5>eb2fZQ%f80QdcT>t|`q%oGn)lle- z;=B|=j+%!p;($m*IB{jW_h+bDvx;!I<}E1x$)$@kX$C`$?mKvzjTiQzSOUoNb88z{ z94S#!I^7o;S`467s~G<@10MvtC-iduM%YmeL+^j6_lAy653*4L#q zZI+wO8OJSOWb`iYe4f3pE0zlq=g6YVrAww;9P|mBy2OCtE;yiUV&5&>N&CD=#=EExja*K2iL`$zK9|1fcThzn*F+hzr>B>fUX2rC42+G}dmSij zZ{67Cg$`OMpx{U(5d_mXlCwv$r*@kH+XOoM7;>3%y;1j(oSvJcaDF4C4^M{KIX%0q zyvn37HS@>M-2uJ;#b#Ttb}*TZm)|)av|{Kkw!kc8fVT+Eq6&rnVG~WqF#fL{#3-Vc zP@F3ljS~38Ju~-Ql;X(BiE^&Bpbxkc;bGm8B^4(fwx^Jibkif=0~1&X*y9Bj3p?Rd zs`Sp`5>;s^-M80ApA$K;I&*+Hj2c>M>(7$F3}QfqL?n$4$$Y+QNjW;K;UO_WEj-ys z|Jg#5;VZ zSg)Ig)$ZkjnPRq531@T=OZe{gDH-5q%$fewaJh3+9a9&diN_I3B8Y88jygey1X^DOcpmp&0!AF&4;u{uK<{bX;#Yt?D|bf4P-k%OrHvDgj3C2yh- zN@epL^WI6R&5o-dqC9syJjN%%zdj^0{0yIXcd9Z^$K@l2`wfd2*)NgRO+%aL(hF+n z-LXs-3dDXAWwIc@g8nzyw#{+k;=P0Z*rLg z|MR*Yj+EpB4K+Y7N(eRuIDtz00NFSW>+4@KMOcjM>f~yruYD`Wo3}l>1!0zb`Q;!d z-qM6WIvMakqIPJ{--_vOG9fXqbk5TaXZ9N$F1(mIHZn@O9N&_pAsyx`Jkae(Io|Uf z7#iH((cg|`$ZTDfD6U9-*{HYHhf_TXO_LGK<_MppH0REvrYvCS0sS;b{*2sJC@C$r zBx8$S+K(Zi!%m3mj&9G}C={BLP`%b6bR+_BJ(^mermJ{1bGDQY>Z=Q*)dVQtUt`a3 z72?Kxfkg{L8PK=Ndi-2l$^6DeIDIEmpiorImxtYhfsfj0~HN>wF(cR$1(N!mc#As*-@UNuc;a=Knd^dvFJD+^##s+ZT& zh^+qcL5OEDivWey_4-pD#F7`Mjws@>{BrygD$AU8lUvNC?3FF-pa1yfB?b% zYls&~B0z{5)e~DwBC&crm?D5xWwqoRiP*`F0Tlws5j8C#uji-HL2gDQ+OybB*T6bE zwZ_@eu@jWTs2W?j{a`EvG#6U}nqegDHobi-Qf9_jynwj@a;u2(JrkAq=JWYJL6%BN z1VGoBVfqFkKZOoE?@Osip*fv2-BVcV7n5x3aZA zPH_d;GMv8@!Suc*CMm^Pa(bL6FM2^K=3quXX3Nyofd@xeuac9{~b=*iRF6#Z_itr0zG%Ts6A9( ze~hM--RzJIpnS};tQdb{3lQ`~v12$wN|Wr#zgZR|0^*FLW+J1VxDjhLk^$jGljS?$ zAlCadQ=nrM=<5_-kH2ya#DYp&BoPwk)O?az_G<1SrpK10Z%(6l!kC;)RJ z4TdQPM|Aeav*-cAbevzZIJQ>0F;n-~m)$_ZoNYQ=E9+MkNhK?e&pSb#;rt=FS)NS~ zR;J8KTZDjoH})vt!J&mf!b{MZg4L`CmC6>Z;|x=n*Y!Kc)pYH9Y84e^rJg4kvZ0_t8Jb=1q%1?s>nXX?WWH#_#frv zypVkamN%DnRn{wkx6}2mQ)=U-+L_{CSEY{0SXsxj4Ho_&R|47~HJ&q$f$W>UyZztF zBPb4#&}rg{YPEHBC-I~bQ9Vvenl+!hJf9sM-vEAqShLzz2@5Wm2LhBr9#a*d z&jHl|5Np<($3i!qk(8fH+iiX?iGgmjrK$oK%161JhYILYYJ;DscZ~<_7Nbi&|E4yC z$9Jtl@(zD{+<3~^-m8C#T`uS+>YSset8B=`1uTFZ5HzVomYFEGJLJhag=~5ofcl84 zQh^pZAKzLkyX2WO(w?EztLDD(q5Hmp2CUKUMz0r83vPeP{;ga|t>uZi1DMbnG>)uu zQ%GORiw2_hxIC8E^70ezcMm5M%j}wlvB=opq}GFy|G#7*Z?*r`G0~j^l;48JW7!n< zy%B^yN8;bNW7BN22oKFG@`2*8Bc0aV>12~YPaGrizH@^h^WU8tX)1rNFPDG}2G8|8 zf4bI@VOm82Ut9Xl&6OB5X52FV(pK+Zi*A$vZjA&;(VVIu=IIT#_KWg7t8$_)2XVkXzBViH>%LGl_t)-3s&IO*XByax1m^^})G z!~S*2CEGYEG<(uZPsGUsqnYZeOd8pI4y(n=S)OgSQH?q$A#ye1WZiu!A{iDZ@!NA9 ztIDc2z^5c9@@PxXFxz&p#MzAIVy2nKe!9GZAVoUh#5}EWK0~yf14V$Z?yvCfkNbSD z!eum_@5GtgP-Bl~ju6kkhHQ-XT3PNyb>h-?yYLCZrW;t{}kWzOtvo5c+*WOmEyOT-taG=C0nt+a6Lc)$=s~5@4IKA z%YXp6PdN!bo#tW|+iuJqZWWZI34c?|5VPD=&WlrAK0n~i=|k&F;v$-JxrxkGtrNc| z6~f45^5a39N#0lhmcyckVn78vD#NIQ!338bKCIo&q$=j zdoB~ls2Im&VRQncge$mu0kDbu9!smS07TGet||#U)^KP;bZ*FW)sf21y<*@MbzD~< z`kjj1Thpm>MfcU-#Jsv0jWXnE6>eX9V%TKV%G|`xCK~Q|c4EEWQGXT~$7sm_^3Lvcx+UF_Z1}^S z5aci(cZeCgt*Bml%lGclAOE36fS^9i@d%?k1&Aktd7{NKr3*Idk$!6JO?L`)Y=Goj zLXdwqHIj0yyXZGVvF?WWikZB8uzHqv4xZcVwL{LUUip(vlawsSuNZlo_i@<{WD!Gl z`x;J5-;_^E!imKQZ>|u-zQx;j^l4t*v-8BD0jGLynyp0l`n+hA^VE}U)|c$EH;$`$ zc7~!~;R>SJM!kM~GCSBj#r=*u%HR9*%>Ev8FDxMz6qUdB_)$RvF4Dp940${z}E5NC|JjkPE5fS&7hSm2_)9wi%#J+{X zJG=BoFzHTlCOFq_qGDOsbb32GkiOPniQy)9i^Q*u67B(Zh>K?2Aq=m zT>aDBszTD^)=cN%B&C}LBC<{E$yHCwCr8Ng!aa*u- zC>66m_0}2e5kyxtio$SK*O|{a(U`r)N|ebO)H~+e1@b>SK{&g;pG$d}IpY?QFtc4j`a3Oc5@n}*rxmsc&_6D5#wpyr##}zMdr%+D&~>2*#qRS`KRYM zbXiE&JFcHKbe9^HtTZDw`2|f}2`dR3COY;53Qt8#^_0#B7CkCMN^3w-l(f$5O3Ar| z#(D9Z@2J_Nnk#afeg#{JLEs_Z zkes>E_ao*}g{DVQ{d#H)+ZPQZ)(2iaXB|7c)&Si?s1Rs@JZ}>Op z_3|jfHQs!*{x7ygediRCznxyMiwcNV?8P>N_L=-SJJc2O)ShK&*5oHWb&SB@1wYMj zO9geY79kL57rvra6JmWayVH)>zGY9G#GGQM|AlPU&?Ns5+4Y={g*u~Gj(@Ob&(+Y^ zAI3z0RW{zwXP4xV z3ZKLd|ArCRim!J1!f%!0d)0$BpqXkWCsx6?GD&LsyT7zBD1uAeZk$Z^sFya-MvB&> zLESQVhTv6IJTfc+B&k?f3urT3>%g2{>eXpy{iSQDj?KGD^x)G4Kcdh;L^_jAFM4_Q zsRMOPM&vO!cc@!!-H`f?6vV9fKFy)-snd}QDncZ)CcBJ4E<2GDuPOZzuQ#9P&H;~P z#N`Q=cvl2@-YrtLrMSjQm)qkCdh6INY`H zvtq*AMHN%NLOK3|E4}edQPlA)sz?0G9&5v({cUVk12Jx(?~Kp2KN<0cMu-$)GM3A* zUSvj^U_%yl_4)xQ2R{bmKsX${Rxhy+XFuh7I~Xkf+^kxC3{($*Kw3H1*_i-z{%kSp zWW3*dwcOXu9q$uN0&iZXNg(m`G#|K&Y5-=w7CjZ4neWkLclL(OtHgV_mCu(dlfcR zA5c1%qyCKRU#I!c?^U@Kjq+J)Wp55!F|@ndq&NfSZ9Tjyp+l1 zyKvAQqiR-lv|Q9BOa6N((`ZP)nzCtv`L>1+V1@KO^->4fAQmNs)x%+f-pzrkfzyAJ zK4BzZ8zjcyu|{jAdA7eTWP}Ij3<$`)!jzG@!PCR>NfQx^wt399aYjJ_36E*rm_M>W zUUA(!pku{6SL6X6KHk_skYiEY)$HqP5-GI+z#6^FIg%?}W2W^=cC|*9HTsZ{uk&la zHlT}E!4>L_EQD+h_@MHX8}x-H=a<}hfMKWx?=8+g6`vg`Fcat*c1Kay^v!u6_FxXR z>^ge0kN>0X8Q5oo)&vDaL^J<3Z5s-#q#3|UY`IRA@66M~dB@)4`SdHY(JDq^OVn6p zBwy3nQdws6X{5&W7)8)@-uq^K zJ6T-79Lmz+@UVpNvSa`yFUO*Xnbw2$su zk?(XiYg}wBjZnh1`QIyGmqPP3@gE_?BjjL&4WZA}JZN}k=oc`N7>}aPb&jQs9d} zW{`j>A%9uIsm+@h>OL{1ubl+BV7rfW?0=l#$e`Y(Wa{Nh>bU|9vwFe40B~CC+kzLr zbTiwvtAj6%vhYWQ*&A*Fz<5-vOhtB}*b?Q~K(bT%S#ssQ#s18Z+Ws84y3@;)s`wFO zOs(K2>SUXd(MNl`zJjRR^sY0j4N@NdrKQ7_&Wg6Zc@D>fX-t%4DWAoCl?)gCN9I5L z|6t3O9{y#@@GbUkK$O5b7Gufsf@JA`I4I3P8FS=6UG|@n`+dF6ykvy@!2}&|n!^J~ zR({D|I#C&)w)8=VXy^AF4f5sE$%Cz>cbs&A$(R!JEK*yxa`Pl zMINbxzExgt!}!wj!Y}I#Bp>Xw1%hvUtuo~L@Y65sbOL9mW!INQVsH8e%FAE3P9_A4 zI3yLE%nN>$6g~LL$2?ZjWX{#X^ucu?Sc;YP51GK3*H0g(QB{k7Q&pwXZ-mUz0%wHG zI_4--1K8@tZBA{cf((#3|AX9TjunLP`$d0;)A1YTlz~ou8%?V>Lyb&S{Dhz5{)3nR zqD?t5Q<%g2KWVF9I~AS;XFVNH&SwG7SkgTO4!>i+|G>V#Xf@wNE4^$nGV{9BPm~c)mEosb z<)P2hoX2IqO|Ge)dYpMAa;VJl6mOPlYN)kd#=Y1ujRYCLwWR^$VXbEol$lP;*qo_W zMC9aApS94}7R!vOCCa}fJ*Fe*FCF|N?^M1~IVP87L(|FH*Jid~<%c?hq3zbfsqyAI z&IYw`WyJaG(ndl^gJr>Tr%2t6uNLXV1tWZoGBiTlr)#(!HG5j$++VRralRCnWcZNs z<)?nGV{#`3C$}RV|5ZvubF_Gwf?Vn4G~n=$qO4DZL&m@{x-A^8Gku=rtq+x&bFdrB zhetun3F8gv=n+J@x~ua_93|_=MeRcZJ;bN3=>=TSf+ESQ`w!3^KFKBjY~TI^@CWiA zy3Z~cjoZ1-ab+)>Dr{A()hxA#a37Zwu=^%AkCqb~emYKKjr@|KtA=36X#OcwwM-@< z#gQz>F016o$`%$wZH0GTkap#cOLX|O$*E{?1joiQ$@u0SwV_@!-xYjX4`{mR`w3srx>ozTd!Yw%x>6zI)=jTEkW{gWs`%gkfIeKjlLytH|80Ku2=V*S&C z#8q(P7cs~0?!B?~<)WQ!xi_Lro^#?QZMJn8X#Zc|FLb7>_dV%Dno_QmI#i6Hv%|Zw zEAjRyv;M@0e@{E`)zyb3bFaMg%d&Zo_^tHl4-k=0IxUjSJ$$mafLM@aeAJuAhceB= zuI6NCr_%U%=orcI3FDr}KFM{z@IWDYLrnnPe*gppb_EHU4psAmxv9n#luqBObjugV zdP8yxt1Xg}>aHiYraHh$j!^!+*NB18lRH&NWHgZMdV55225dT?_xg^0;JxkbK~@6Y zqqVqepmF#1KKS9*bAipA0cGbe&dGY=ZcThhj#RoLC}(qAmXqV|2T{&Zo#;FN7{PqX zYaHH(Wmz3ss6Tt{VItXyv5QeO85Vi-AMZjwy`Cr8?@x&Ax|cMipZmJwuvF+3>m*&! zHN}Ebz3~VqcV?k>m2jJ?!?DzGLiWuWv$HIq7$MtaD=`WP#S+Rv=K}&&vpKf2-8Hoh zdSLc(GKWI0`uf8!FTIG7(b!b@yV;=(_xM;n>ezeXiz7H?PXAk1?wytEs_qfXuse6% z!mC_kpn3qhbo7K+wAkUW$v^WN{`{BS|7)u9<7d1mv6+@5?o{O|&3wz$V|{Ri_@lu| zSU1E?>gwAe$J(lC`cbe+49J>FR=nMBxANY-*N{8PEJuCWpxW-d=kSBY&exp1{R>|h zh2NOTM1Q;Dqsi93+2E9FPGCv(T|M3z0M{*b&0In+k-4q;VpBXS3M1ZAiHWgkVppi$ zlpcL!iTS!6bj_d}AsMy--P{ZaVCB#6=Up7pUpRbc z|Ew5w(S}np3DkM1OGBAWukZ*_KGKQ09S!@sExph$FKX^>{!I0nN-SRNHMSgkwzc}! z5+QSg5l$}pQHqnrD&2M{C#HDKmG4ZT+Q`0R&8JGoL)fVv2`-qzvF?vNcUd2VN4sr` z0+EdGm$m6sJK3aVXQWIIsE;c3>x(gVXoozVy{_EE^qMm&c49`$*WAz9%^uY@=}a-K z!|8)o5L*jpEWdBq-DPMo3)m_rZ-|DG=1~jpE+~3nkCKmZMh+_@U>8ur>(!5SeN7qO ziJiU0g*p@ogC<|M91RAhDFuqM`&fV69~7g^RfwQEh0!O6oGPr2a#H+^OjEnd+irRk zWO6=F^Lg%Rw0n~~`K0mybTpv>=JE7&ji}S8bcpnH|61P*L}Muq{%#y0cm1@%!|8|y zxsA^K>CdcOJTCBF-B?tR`xYhT_VaACsfYXKRC&et`lHnGPjXul8~I2d~lZRc~TXXD-J}Wu@ z`uw`7K9H^5Hu}rZlu~@S)s5}OAIgmfcYH+WYZpG>`kA6wB9<;s(yx;g0^)YK3tJak zA7#|;>FERMk*L{f-_5N8>R709(z{Yz6&ElN^wC@63!@SojSb10eHxbLaCcX>Mw@FaQp`E9RH z8L|beW5dU@QW5+WtCiycEIaGCTxYOJ-(l?ud;XERLwbGUROsYkHDY&H`?uRK%7*ie zR|Sq5d5)}b%h*O@Tyb)2LkmUPNR#zUQz~V#{ve_WuvypK-JzpX+ja#k{Do!q6ly2_ zyhf?E)?t@*V&CKa>ga&E&YSBS)i7MXn7QXxTXCD%P-J}R_6FOX88L^k;4VnpyoS%? z*&U&pOG8OYi1g-Wt##7J9k#mlHqH{_vJHIOu1Sf_Aq+00?s^jJqCL=|0@I_cW#Djv zB@F4j_tWU!M7Oac#FSrP<9jdjdNxuYZdMxwzu9Y0>J}Ur8sht9QMvj1$ZD{d!%)=B z1S6YcrL&3e96U6StkoyR>HKwP64g6X!KAu^S(wa_@WGrgq^zD_jS4 z-iYDiX`?3}XP1UcagXCs(bE~vF8X^6I!6zpzFvGUpk)?~u;43H5%(?CWnxUBD zl4>nlE=OZv@PM7IK@{^wEdh3@_i!k~oK6vb_{oxoV6>-^b7LfC zRWj&ZgR-9#LdT3}C0z7k{tbXO$emVqhY~mo(RFZ@oCT@xn|mKIg$}P_93T4Jn@fGGtt>zs(lbvDNQ3Z^7IT+>>Pq&qr8iIDhn1pD{2rLOzaq?>7*3f+ ziryyWS2W#;6NY+VJcbr-NugKzP2m%7n6kub08BW28|E?DbU zvq@I(@qjymc3ORi^EWTDk#x--SX~$?t>K(CV}**QhRv@h@Ax?*C4zLIZbnul?P*0B z9|7$uKHt22VD8S(d^RtrAUrEOxt%TYx&K<9S@e_hYWJJO(Z$H;lP^Yv7G4oqKmTd(|! zeZjqODV(P2g|<8lFcO|**o}s+jJM@W_!6SU;v^eEvpTUivkGCYd)mnBhoSD<#qm1- z;38eqr5J^&?wPf#yIiOe&yO%n#uCgN{E=U(j{J>y|MvU`nGAeL z9K@*Z?Lz_HiqIcGF@S*~p-vG)}klN8;9-9%*rM{6_DhJxkNwW0Y&0M!S4b-@Zn`fqE+aq_^2 zy*Efar8&9zXr)b}uzsV@%rSx2D$oY)E~LJcnSY)g$V+)|XD*&<(s%e`iD(}+9?Rj# zJI+t@cs+wOh#*b=*)V_vN=-sn|5g1*8Np{&aDTjjLI@xG?)s^f8aA5DQ_3Zdg7iD14@=fuj zXtrydTa|}uVG0hLq9HC^8v=6?3v@ABbtiS^4kAR-n~Q~Nmm%vy$Ht`EP!XV`RVI&y ziK!vJ92l;DWbWhunZ1G#M=w_n@fxs-R+6pjoPSZ5xX(ASyss@Mgk$UWu1W5saW{9{LX|D9WWvv;Iy25lZUnh{0WBruh$c48+OWdA>z3Cn zI>GjLMrMAvfJY^6=t~G>54MG?aRtc($ZFI($-K{@bj5mcRs>@6=qsq99S4Y`ksBc$ z;b2}Ry82)v?cpcaDWQRCfYBOR^fxcZ-}44k?c%aFq)AB1>5MpGxv!+*>p;vKRL#

)9q6INF-U(%${s`r)62n{u7J>V|g@Lky2deURN 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 literal 0 HcmV?d00001 diff --git a/docs/assets/plugin-marketplace-layout/before-dark.png b/docs/assets/plugin-marketplace-layout/before-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..28715eb59cbf7cf2c9950b04708851ed7f239670 GIT binary patch literal 240081 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%0A+u@;vj~vp@*}YP-aR?>Feb2!0p-P9% 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¾tx%(ZgODch== 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 GpZ*WZU8`~c literal 0 HcmV?d00001 diff --git a/docs/assets/plugin-marketplace-layout/before-light.png b/docs/assets/plugin-marketplace-layout/before-light.png new file mode 100644 index 0000000000000000000000000000000000000000..4cb57f2f8754b111fd2bb9d443f7e6c082f4a744 GIT binary patch literal 232119 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�cAyB>hY)$-3Rc!nGLmVFCm20h2L-*C`N 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%0Do$?Ch@s#8k`V&G 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

    < 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 z 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 jwZR6ip(_ title: String, @ViewBuilder value: () -> Content) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: Self.metadataSpacing) { Text(LocalizedStringKey(title)) .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 96, alignment: .leading) + .frame(width: Self.labelWidth, alignment: .leading) value() .textSelection(.enabled) } diff --git a/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift b/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift new file mode 100644 index 0000000000..073ecfe212 --- /dev/null +++ b/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift @@ -0,0 +1,174 @@ +import AppKit +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +@Suite("Marketplace split bounds", .serialized) +struct PluginMarketplaceLayoutTests { + @Test(arguments: ["en", "zh-Hans"], [ColorScheme.light, .dark]) + func marketplaceContentStaysOnItsSideDuringResizeAndDrag(language: String, scheme: ColorScheme) async throws { + let service = MarketplaceLayoutService() + let feature = PluginMarketplaceFeatureModel(service: service, pluginManager: MarketplaceLayoutManager()) + await feature.refreshIfNeeded() + let domain = "lithe-marketplace-layout-\(UUID().uuidString)" + let preferences = try #require(UserDefaults(suiteName: domain)) + defer { preferences.removePersistentDomain(forName: domain); feature.endInstallationSession() } + preferences.set(420.0, forKey: "lithe.settings.pluginMarketplaceListWidth") + let host = NSHostingView(rootView: PluginMarketplaceView(feature: feature) + .defaultAppStorage(preferences) + .environment(\.locale, Locale(identifier: language)).environment(\.colorScheme, scheme)) + host.sizingOptions = [] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 700), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) + window.contentView = host + window.orderBack(nil) + defer { window.contentView = nil; window.close() } + for (width, height) in [(1100.0, 700.0), (900, 500), (700, 700), (585, 700), (900, 900)] { + window.setContentSize(NSSize(width: width, height: height)) + host.layoutSubtreeIfNeeded() + if width == 585 { try Self.capture(host, name: "narrow-\(language)-\(scheme == .dark ? "dark" : "light")") } + try await Self.checkBounds(host) + } + let handle = try #require(Self.descendants(host).compactMap { $0 as? SplitHandleInteractionView }.first) + for distance in [-180.0, 240, -80] { + let original = handle.convert(handle.bounds, to: host) + let origin = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil) + func event(_ type: NSEvent.EventType, distance: CGFloat = 0) throws -> NSEvent { + try #require(NSEvent.mouseEvent(with: type, location: NSPoint(x: origin.x + distance, y: origin.y), + modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)) + } + // Go through the product's native hit target and the window's drag + // delivery, rather than invoking a layout callback directly. + window.sendEvent(try event(.leftMouseDown)) + window.sendEvent(try event(.leftMouseDragged, distance: distance)) + window.sendEvent(try event(.leftMouseUp, distance: distance)) + try await Self.settle(host) { abs(handle.convert(handle.bounds, to: host).midX - original.midX) > 1 } + try await Self.checkBounds(host) + let moved = handle.convert(handle.bounds, to: host) + // A late feature refresh must not move the divider after release. + await feature.refreshAll() + host.layoutSubtreeIfNeeded() + #expect(abs(handle.convert(handle.bounds, to: host).midX - moved.midX) < 1) + try await Self.checkBounds(host) + } + try Self.capture(host, name: "marketplace-\(language)-\(scheme == .dark ? "dark" : "light")") + } + + private static func capture(_ host: NSView, name: String) throws { + if let directory = ProcessInfo.processInfo.environment["LITHE_MARKETPLACE_LAYOUT_CAPTURES"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: output.appendingPathComponent("\(name).png")) + } + } + + private static func checkBounds(_ host: NSView) async throws { + host.layoutSubtreeIfNeeded() + let handle = try #require(descendants(host).compactMap { $0 as? SplitHandleInteractionView }.first) + let divider = handle.convert(handle.bounds, to: host).midX + let expected = (host.window?.contentView?.bounds.width ?? host.bounds.width) + #expect(divider >= PluginManagementView.listMinimumWidth) + #expect(divider < expected - PluginManagementView.detailMinimumWidth) + let scrolls = descendants(host).compactMap { $0 as? NSScrollView } + let detail = try #require(scrolls.max { $0.convert($0.bounds, to: host).minX < $1.convert($1.bounds, to: host).minX }) + let viewport = detail.convert(detail.bounds, to: host) + #expect(abs(viewport.minX - divider - SplitHandleView.thickness / 2) < 1) + #expect(abs(viewport.maxX - host.bounds.maxX) < 1) + let document = try #require(detail.documentView) + let content = document.convert(document.bounds, to: host) + for id in ["plugin-marketplace-name", "plugin-marketplace-install", "plugin-marketplace-description"] { + let frame = try await AgentNativeControlTestSupport.frame(of: id, in: host) + #expect(frame.minX >= divider, "\(id) crosses divider \(divider): \(frame)") + #expect(frame.maxX <= content.maxX + 1, "\(id) overflows its scroll document: \(frame)") + if id == "plugin-marketplace-name" { + let top = host.isFlipped ? frame.minY - viewport.minY : viewport.maxY - frame.maxY + #expect(top < 40, "Short plugin details must stay at the top: \(top)") + } + } + for scroll in scrolls { + let frame = scroll.convert(scroll.bounds, to: host) + #expect(frame.minX >= -1 && frame.maxX <= host.bounds.maxX + 1) + } + // At 160pt the full source picker cannot fit. All content remains + // reachable, with the native clip view still confined to the detail. + if document.bounds.width > detail.contentView.bounds.width + 1 { + detail.contentView.scroll(to: NSPoint(x: document.bounds.maxX - detail.contentView.bounds.width, y: 0)) + detail.reflectScrolledClipView(detail.contentView) + #expect(abs(detail.contentView.documentVisibleRect.maxX - document.bounds.maxX) < 1) + detail.contentView.scroll(to: .zero) + detail.reflectScrolledClipView(detail.contentView) + } + } + + private static func settle(_ host: NSView, until predicate: () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if predicate() { return } + await Task.yield() + } while clock.now < deadline + Issue.record("Marketplace split did not finish the native drag within two seconds") + } + + private static func descendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + descendants($0) } + } +} + +@MainActor +private final class MarketplaceLayoutService: PluginMarketplaceServicing { + let platform = "darwin-arm64" + private let source = PluginMarketplaceSource(id: "layout-source", name: "Lithe Store", + input: "https://github.com/example/plugins", indexURL: "https://example.invalid/index.json", + repository: "https://github.com/example/plugins", isEnabled: true) + private var index: PluginMarketplaceIndex { + .init(plugins: [.init(id: "io.example.cargo", name: "Cargo", author: "Lithe Community", + repository: "https://github.com/example/lithe-plugin-cargo", homepage: "https://example.invalid/cargo", + description: "Cargo build integration with workspace and package tasks, release profiles, features and offline builds.", + localizedDescriptions: ["zh-Hans": "Cargo 构建预发布插件:原生 workspace/包编译、检查、测试、清理及 binary/example 运行,支持 Release、Features 和 Offline。较新的版本需要支持对应能力的宿主。"], + tags: ["build", "cargo", "rust", "testing"], license: "Apache-2.0", icon: nil, + platforms: ["darwin-arm64", "darwin-x64"], latestVersion: "0.1.1", latestReleaseDate: "2026-10-22", + availability: .available(.init(version: "0.1.0", pluginAPIVersion: 3, releaseDate: "2026-10-21", + downloadURL: "https://example.invalid/plugin.zip", sha256: String(repeating: "a", count: 64))), + versions: [.init(version: "0.1.0", pluginAPIVersion: 3, releaseDate: "2026-10-21", platforms: ["darwin-arm64", "darwin-x64"])])], rejected: []) + } + func loadSources() -> [PluginMarketplaceSource] { + [source, .init(id: "other-layout-source", name: "Other Store", input: "https://github.com/example/other", + indexURL: "https://example.invalid/other.json", repository: "https://github.com/example/other", isEnabled: true)] + } + func saveSources(_ sources: [PluginMarketplaceSource]) throws {} + func resolveSource(_ input: String) throws -> PluginMarketplaceSourceResolution { throw PluginMarketplaceIconRejected() } + func cachedIndex(for source: PluginMarketplaceSource) -> PluginMarketplaceIndexSnapshot? { .init(index: index, fetchedAt: nil) } + func refreshIndex(for source: PluginMarketplaceSource) async throws -> PluginMarketplaceIndexSnapshot { .init(index: index, fetchedAt: nil) } + func removeCache(for source: PluginMarketplaceSource) {} + func iconData(from icon: String) async throws -> Data { throw PluginMarketplaceIconRejected() } + func prepareInstall(entry: PluginMarketplaceEntry, download: PluginMarketplaceDownload, source: PluginMarketplaceSource, + approvedPrograms: [PluginProgram]?) async throws -> PreparedPluginPackage { throw PluginMarketplaceIconRejected() } + func discard(_ prepared: PreparedPluginPackage) {} + func prepareDiskInstall(at url: URL) async throws -> PreparedDiskPluginPackage { throw PluginMarketplaceIconRejected() } + func discard(_ prepared: PreparedDiskPluginPackage) {} +} + +@MainActor +private final class MarketplaceLayoutManager: PluginManaging { + let snapshots: [PluginManagementSnapshot] = [] + let issues: [PluginManagementIssue] = [] + func setEnabled(_ enabled: Bool, for pluginID: PluginID) async throws {} + func setSetting(_ value: ToolingJSONValue?, id settingID: String, for pluginID: PluginID) throws {} + func installMarketplacePackage(at packageURL: URL, origin: PluginInstallationOrigin, provenance: PluginMarketplaceProvenance) throws {} + func installDiskPackage(at packageURL: URL, provenance: PluginDiskProvenance) throws {} + func installPackage(at packageURL: URL) throws {} + func rollback(_ pluginID: PluginID) throws {} + func uninstall(_ pluginID: PluginID) async throws {} + func restore(_ pluginID: PluginID) throws {} +} diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index 5c65523f6c..a355a15d87 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -36,7 +36,8 @@ ".github/workflows/release-official-plugins.yml", "macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift", "macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift", - "macos/Tests/LitheTests/MacApplicationRelauncherTests.swift" + "macos/Tests/LitheTests/MacApplicationRelauncherTests.swift", + "macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -52,5 +53,5 @@ }, "owner": "Extensions", "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。;市场列表与详情之间的分隔条使用真实页面回归:中英文描述与深浅主题下缩放窗口、拖动后释放,再刷新索引,详情视口始终位于分隔条右侧;正文按视口宽度换行,窄于来源选择器和提示卡所需宽度时可横向滚动至末端,短详情保持顶部对齐。测试使用私有临时偏好域,关闭窗口后清理;仅原生组件证据,整体实机验收仍 pending。" } From 76613015af1e95701832af2d0905ade916f6c51b Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:42:37 +0800 Subject: [PATCH 07/50] =?UTF-8?q?feat(agent):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E3=80=81=E5=85=A8=E5=B1=80=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E4=B8=8E=20MCP=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=20(#1381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): add native global and project skill management * fix(macos): make skill toggles respond immediately * feat(agent): 新增原生全局指令并稳定技能开关反馈 在 AI 设置中保留独立的指令与技能入口,直接编辑 Codex/Claude 用户指令文件,提供草稿、冲突校验和原子保存。为技能成功提示保留布局空间,覆盖连续启停、深浅主题和窄宽页面的原生几何回归。 * fix(agent): 技能保存仅锁定当前行,避免其他开关变灰 将待确认选择按点击顺序串行保存,其他技能保留原生启用外观和交互能力。覆盖同一 Agent 连续写入、单行失败回滚、队列继续、重复点击去重、项目切换及离页清理;同步 Agent Note 与平台矩阵。 * feat(agent): add shared instruction cards and native synchronization * feat(mcp): add bundled publisher icons and common featured servers * feat(mcp): 增加工具检测与原生工具开关 * fix(agent): preserve cancellation and native MCP tool filters * fix(skills): publish Windows skill packages without replacement * fix(ci): bound multi-repository tests and join search fixture teardown * test(windows): await owned process exit signals and clean fixtures * fix(agent): close cancellation and MCP policy lifecycle gaps * test(ci): bound Windows frontend path roundtrip startup * fix(ci): match raw path test names for scoped deadlines * test(ci): scope checkout and clone integration timing allowance * fix(ci): allow Agent Host cold compilation within Rust suite budget --- ...09-12-git-execution-and-project-console.md | 6 + ...module-runtime-boundaries-and-lifecycle.md | 7 + ...026-09-25-shared-acp-agent-conversation.md | 2 +- .../2026-09-27-windows-git-path-identity.md | 1 + ...-10-14-child-processes-end-with-the-ide.md | 1 + .../2026-09-27-workspace-git-commit-plans.md | 1 + .../2026-10-15-macos-settings-hierarchy.md | 2 +- .../feature/2026-10-17-mcp-server-library.md | 112 +++- .../2026-10-20-agent-skill-management.md | 102 +++ .../2026-10-21-agent-global-instructions.md | 93 +++ ...13-ci-build-cache-and-artifact-strategy.md | 9 + .../2026-09-30-macos-shared-dropdown-style.md | 10 + .../references/windows-and-rust.md | 39 +- .../scripts/test-stability-windows.ps1 | 5 + .github/workflows/ci-windows.yml | 3 + Package.swift | 1 + docs/ci-builds.md | 36 + macos/Resources/en.lproj/Localizable.strings | 220 ++++++ .../zh-Hans.lproj/Localizable.strings | 217 ++++++ .../Application/Composition/AppServices.swift | 5 + .../AgentInstructionsFeatureModel.swift | 181 +++++ .../Features/AgentSkillsFeatureModel.swift | 189 ++++++ .../Features/McpRegistryFeatureModel.swift | 89 +++ .../McpServerLibraryFeatureModel.swift | 133 +++- .../Features/McpToolsFeatureModel.swift | 120 ++++ .../Core/Ports/McpConfigurationStore.swift | 17 +- .../Lithe/Core/Ports/McpRegistry.swift | 35 + macos/Sources/Lithe/Core/Ports/McpTools.swift | 45 ++ .../Rust/RustAgentInstructionsService.swift | 78 +++ .../Rust/RustAgentManagementService.swift | 57 +- .../Core/Rust/RustCoreCancellation.swift | 30 + macos/Sources/Lithe/LitheApp.swift | 1 + .../Lithe/Models/AppModel/AppModel+MCP.swift | 2 + .../Lithe/Models/AppModel/AppModel.swift | 2 + .../AppModel/AppModelSupportTypes.swift | 6 +- .../Platform/MacOS/MCP/MacMcpRegistry.swift | 82 +++ .../Platform/MacOS/MCP/MacMcpTools.swift | 163 +++++ .../Platform/MacOS/MacServiceContainer.swift | 6 + .../Resources/McpIcons/LICENSE-Playwright.txt | 202 ++++++ .../Lithe/Resources/McpIcons/NOTICE.json | 46 ++ .../Resources/McpIcons/chrome-devtools.png | Bin 0 -> 7315 bytes .../Lithe/Resources/McpIcons/context7.png | Bin 0 -> 3157 bytes .../Lithe/Resources/McpIcons/deepwiki.png | Bin 0 -> 1584 bytes .../Sources/Lithe/Resources/McpIcons/exa.png | Bin 0 -> 2208 bytes .../Lithe/Resources/McpIcons/github.png | Bin 0 -> 2397 bytes .../Sources/Lithe/Resources/McpIcons/mcp.png | Bin 0 -> 2108 bytes .../Resources/McpIcons/microsoft-learn.png | Bin 0 -> 425 bytes .../Lithe/Resources/McpIcons/playwright.svg | 9 + macos/Sources/Lithe/Theme/McpBrandIcon.swift | 62 ++ .../Agent/AgentInstructionsSettingsView.swift | 231 +++++++ .../Views/Agent/AgentPanelSettingsView.swift | 2 + .../Lithe/Views/Agent/AgentSkillSheets.swift | 112 ++++ .../Views/Agent/AgentSkillsSettingsView.swift | 181 +++++ .../Lithe/Views/App/McpDiscoveryView.swift | 213 ++++++ .../Views/App/McpServerLibraryView.swift | 438 ++++++------ .../Lithe/Views/App/McpToolsPanel.swift | 100 +++ .../Views/App/SettingsSearchVocabulary.swift | 4 + .../Lithe/Views/App/SettingsView.swift | 13 + .../Views/Components/LitheContextMenu.swift | 42 +- .../Agent/AgentInstructions.swift | 66 ++ .../Agent/AgentManagementPorts.swift | 15 + .../Agent/AgentSkills.swift | 65 ++ .../AgentInstructionsAppearanceTests.swift | 115 ++++ .../AgentInstructionsCancellationTests.swift | 79 +++ .../AgentInstructionsFeatureModelTests.swift | 145 ++++ .../AgentSkillsCancellationTests.swift | 94 +++ .../AgentSkillsFeatureModelTests.swift | 616 +++++++++++++++++ .../LitheActionMenuFocusTests.swift | 90 +++ .../McpArgumentRoundTripTests.swift | 349 +++++++++- .../LitheTests/McpBatchTransactionTests.swift | 36 + .../McpRegistryIntegrationTests.swift | 18 + macos/Tests/LitheTests/McpRegistryTests.swift | 194 ++++++ .../LitheTests/McpServerLibraryTests.swift | 12 + .../LitheTests/McpToolsFeatureTests.swift | 162 +++++ .../SearchEverywhereBehaviorTests.swift | 8 + .../SettingsSearchVocabularyTests.swift | 14 + .../SettingsSidebarLayoutTests.swift | 2 +- .../SharedInstructionsFeatureTests.swift | 139 ++++ rust/Cargo.lock | 32 + rust/lithe-agent-host/Cargo.toml | 9 + rust/lithe-agent-host/src/codex_history.rs | 116 +++- rust/lithe-agent-host/src/instructions.rs | 324 +++++++++ .../src/instructions/shared.rs | 416 ++++++++++++ .../src/instructions/shared/tests.rs | 228 +++++++ .../src/instructions/tests.rs | 203 ++++++ rust/lithe-agent-host/src/lib.rs | 3 + rust/lithe-agent-host/src/mcp_probe.rs | 284 ++++++++ .../src/mcp_probe/http_client.rs | 174 +++++ rust/lithe-agent-host/src/mcp_probe/tests.rs | 236 +++++++ rust/lithe-agent-host/src/skills.rs | 281 ++++++++ rust/lithe-agent-host/src/skills/files.rs | 523 +++++++++++++++ rust/lithe-agent-host/src/skills/settings.rs | 298 +++++++++ rust/lithe-agent-host/src/skills/tests.rs | 632 ++++++++++++++++++ rust/lithe-core/Cargo.toml | 1 + rust/lithe-core/src/agent/instructions.rs | 51 ++ rust/lithe-core/src/agent/mod.rs | 18 + rust/lithe-core/src/mcp/featured.json | 183 +++++ rust/lithe-core/src/mcp/mod.rs | 45 ++ rust/lithe-core/src/mcp/plan.rs | 84 ++- rust/lithe-core/src/mcp/registry.rs | 361 ++++++++++ rust/lithe-core/src/mcp/tests.rs | 55 ++ rust/lithe-core/src/mcp/tools.rs | 254 +++++++ rust/lithe-core/src/mcp/tools/tests.rs | 114 ++++ rust/lithe-core/src/protocol/command.rs | 27 + rust/lithe-core/src/runtime/dispatcher.rs | 27 + rust/lithe-core/src/tests/agent.rs | 43 ++ rust/lithe-core/tests/git_path_roundtrip.rs | 22 +- scripts/reuse-worktree-resources.mjs | 6 + scripts/test-reuse-worktree-resources.mjs | 23 +- scripts/verify-rust-core-crates.sh | 3 +- scripts/worktree-resources.json | 41 +- shared/contracts/application-boundary.md | 34 + shared/contracts/mcp-registry-page.json | 36 + shared/contracts/rust-core-api.md | 187 +++++- .../fixtures/agent/agent-instructions-v1.json | 40 ++ .../agent/shared-instructions-v1.json | 43 ++ .../fixtures/agent/skills-management-v1.json | 144 ++++ shared/fixtures/mcp/featured-setup-v1.json | 48 ++ shared/fixtures/mcp/tools-v1.json | 52 ++ .../features/agent-global-instructions.json | 40 ++ .../features/agent-skill-management.json | 37 + .../macos-shared-product-dropdowns.json | 7 +- .../features/mcp-registry-discovery.json | 34 + .../features/mcp-server-library.json | 15 +- .../features/run-processes-end-with-ide.json | 2 +- windows/tauri/src-tauri/Cargo.lock | 61 ++ windows/tauri/src-tauri/src/run.rs | 170 +++-- 127 files changed, 11730 insertions(+), 319 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-20-agent-skill-management.md create mode 100644 .agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md create mode 100644 macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift create mode 100644 macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift create mode 100644 macos/Sources/Lithe/Application/Features/McpRegistryFeatureModel.swift create mode 100644 macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift create mode 100644 macos/Sources/Lithe/Core/Ports/McpRegistry.swift create mode 100644 macos/Sources/Lithe/Core/Ports/McpTools.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpRegistry.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift create mode 100644 macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt create mode 100644 macos/Sources/Lithe/Resources/McpIcons/NOTICE.json create mode 100644 macos/Sources/Lithe/Resources/McpIcons/chrome-devtools.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/context7.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/deepwiki.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/exa.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/github.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/mcp.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/microsoft-learn.png create mode 100644 macos/Sources/Lithe/Resources/McpIcons/playwright.svg create mode 100644 macos/Sources/Lithe/Theme/McpBrandIcon.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift create mode 100644 macos/Sources/Lithe/Views/App/McpDiscoveryView.swift create mode 100644 macos/Sources/Lithe/Views/App/McpToolsPanel.swift create mode 100644 macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift create mode 100644 macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift create mode 100644 macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift create mode 100644 macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift create mode 100644 macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift create mode 100644 macos/Tests/LitheTests/AgentSkillsCancellationTests.swift create mode 100644 macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift create mode 100644 macos/Tests/LitheTests/LitheActionMenuFocusTests.swift create mode 100644 macos/Tests/LitheTests/McpRegistryIntegrationTests.swift create mode 100644 macos/Tests/LitheTests/McpRegistryTests.swift create mode 100644 macos/Tests/LitheTests/McpToolsFeatureTests.swift create mode 100644 macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift create mode 100644 rust/lithe-agent-host/src/instructions.rs create mode 100644 rust/lithe-agent-host/src/instructions/shared.rs create mode 100644 rust/lithe-agent-host/src/instructions/shared/tests.rs create mode 100644 rust/lithe-agent-host/src/instructions/tests.rs create mode 100644 rust/lithe-agent-host/src/mcp_probe.rs create mode 100644 rust/lithe-agent-host/src/mcp_probe/http_client.rs create mode 100644 rust/lithe-agent-host/src/mcp_probe/tests.rs create mode 100644 rust/lithe-agent-host/src/skills.rs create mode 100644 rust/lithe-agent-host/src/skills/files.rs create mode 100644 rust/lithe-agent-host/src/skills/settings.rs create mode 100644 rust/lithe-agent-host/src/skills/tests.rs create mode 100644 rust/lithe-core/src/agent/instructions.rs create mode 100644 rust/lithe-core/src/mcp/featured.json create mode 100644 rust/lithe-core/src/mcp/registry.rs create mode 100644 rust/lithe-core/src/mcp/tools.rs create mode 100644 rust/lithe-core/src/mcp/tools/tests.rs create mode 100644 shared/contracts/mcp-registry-page.json create mode 100644 shared/fixtures/agent/agent-instructions-v1.json create mode 100644 shared/fixtures/agent/shared-instructions-v1.json create mode 100644 shared/fixtures/agent/skills-management-v1.json create mode 100644 shared/fixtures/mcp/featured-setup-v1.json create mode 100644 shared/fixtures/mcp/tools-v1.json create mode 100644 shared/platform-feature-matrix/features/agent-global-instructions.json create mode 100644 shared/platform-feature-matrix/features/agent-skill-management.json create mode 100644 shared/platform-feature-matrix/features/mcp-registry-discovery.json diff --git a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md index 137dbcec26..3694927485 100644 --- a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md +++ b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md @@ -253,6 +253,12 @@ JDT 通道曾触发其 15 秒测试期限,同一提交在 SharedRust 通道用 所有测试的时限来隐藏这类问题。计时工具用可控时钟验证普通、集成和总截止 时间,并验证 HTML 与 JUnit 不会把合法的集成耗时误判为失败。 +checkout/clone 完整流程用例 `git_write_executes_checkout_preflight_clone_and_validation` +在 Windows CI 39935852248 中全部断言通过,Rust 内部计时 15.22 秒、测试进程计时 +18.529 秒,超过默认十五秒后触发清缓存重编,继而耗尽原套件期限。该用例创建分叉 +分支,覆盖干净、脏和未跟踪文件预检、智能/强制切换以及本地 clone,因此只为它在 +SharedRust 和真实 JDT 两个入口设置三十秒预算;不延长请求或整套期限,不重试失败断言。 + 取消模块的确定性回归核对 mutation 后正常读取保留原截止时间和取消能力, 已取消或超时请求的独立读取仍受清理期限约束,错误和异常退出后恢复原状态。 真实仓库 rebase 回归核对 edit、conflict、continue、skip、abort 和过期 amendment; diff --git a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md index 1502f95f28..c0a4201b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md @@ -89,6 +89,13 @@ MacServiceContainer 错误,不能把残留资源伪装成成功禁用。上述任务仍在主执行器上使用异步等待, 不阻塞线程、不新增插件代码加载能力。 +使用真实 AppModel 的测试也必须遵守项目打开的清理顺序。`openProjectDirectly` 会启动 +模块图关闭;项目文件快照已经发布不代表关闭完成。Safe Mode 测试要手动注入能力时, +先通过有截止的 `WorkbenchTestScope.run` 等待 `awaitModuleRuntimeShutdown()`,再放入 +测试能力。否则迟到的 shutdown 事件可能移除刚注入的能力,让前几次搜索通过、随后 +查询全部为空。不要用睡眠或反复重新注入能力掩盖这个顺序;搜索排除回归与插件页面 +生命周期测试都使用同一个明确的完成边界。 + ### 数据库视图在模块释放后的刷新 退出应用或休眠数据库模块时,SwiftUI(macOS 的声明式界面框架)可能先刷新 diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index 19607a5f84..fbd9b412cc 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -175,7 +175,7 @@ macOS 的审批模式、模型和思考强度在等待、推理、输出、工 - **思考(`agent_thought_chunk`)**:存为独立的 `thought` 角色消息,不并进 Agent 回复。流式缓冲按角色记录,角色切换先刷新上一段,所以“思考 → 回复 → 工具 → 思考”保持原顺序。只有正在流式输出的最后一段思考默认展开,后续回复或工具到达后自动收起,用户手动展开的状态保留;搜索时临时展开匹配的思考,优先于已有的手动折叠选择;搜索期间不允许再次收起命中文字,清空搜索后恢复手动选择,不把搜索结果写回折叠偏好。手动偏好由 `AgentTranscriptView` 在视图局部内存中按会话 ID 与消息 ID 保存,再以绑定传给思考行;搜索先过滤消息,会移除并重建行,因此不能让行内状态成为偏好的所有者。切换会话保留各自选择,关闭会话或移除实际消息时释放对应偏好,销毁对话视图时一并释放;不写入功能模型、共享 Host、历史文件或安装目录,不影响代码签名和 Sparkle delta。`AgentThoughtTranscriptTests` 直接挂载实际对话容器,通过原生鼠标事件和完整渲染快照覆盖搜索移除/重建、命中时临时展开和会话切换。导出 Markdown 用引用块写出思考,便于和回复区分;历史消息数仍只统计用户与 Agent 回复。活跃思考块的标题显示“思考中…”,底部等待行改为“回复中…”并保留本轮计时,避免重复提示;取消时仍显示“正在停止…”。 - **计划(`plan`)**:ACP 规定每次都发完整计划,所以整体替换,不做合并;空列表清除计划,格式错误的更新忽略,最多保留 100 条。计划条状态只认 `pending`、`in_progress`、`completed`,未知状态的条目丢弃而不是猜测。点击底部“任务”打开计划与进度;没有计划时显示“暂无任务”,不把工具调用充当计划。`session/load` 回放会自然恢复计划。 -- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。Lithe 不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留默认两行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 +- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。对话补全不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。独立技能设置页可以展示原生发现结果或明确标注的目录清单,但不得据此伪造会话补全和启用状态;管理边界见 [技能管理](../feature/2026-10-20-agent-skill-management.md)。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留默认两行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 - **模式通知(`current_mode_update`)**:Agent 自己切换模式(例如退出计划模式)时,只更新 `category == "mode"` 且包含该值的配置选项,不向上游回发设置请求;选项中不存在的模式 ID 忽略,不显示原始 ID。 - **即时通知(`notice`)**:共享 Host 启用固定 SDK 的 `unstable_session_notices` 能力,并声明 `clientCapabilities.session.notices: {}`,让适配器通过独立事件发送警告。macOS 每个会话只保存最新一条通知,用现有 `LitheBanner` 展示和关闭;通知不计入回复、历史导出或轮次统计,下一条 prompt 成功提交及断连时清除。上游通知字段限制为标题 256 字符、详情 4096 字符,未知严重程度和空标题忽略。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md index d35c1f5c38..83eb3692ce 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md @@ -33,6 +33,7 @@ Issue #627 中,前端先判断绝对路径再替换反斜杠,把原生 UNC - `shared/fixtures/git/windows-paths.json` 由前端与 Rust 测试共同读取。 - `git_path_roundtrip` 原生集成测试使用真实 Git 仓库,把发现结果交给实际前端 TypeScript 规范化函数后回传 Core,核对提交、分支、stash 和链接工作树的监听目录。它需要 Git 和 Node.js 22.6+,子进程通过现有 Git Host 设置本地期限并清理进程树。 + - Windows CI 39933419913 的超长路径用例在 Node 子进程十秒截止时返回 `Cancelled`,没有产生路径结果不匹配。测试仅将 Windows 的真实 Node/TypeScript 加载期限改为二十秒,Git 夹具命令仍为十秒、Core 请求仍为五秒;SharedRust 与真实 JDT 两个入口为 `git_paths_roundtrip_` 设置单例六十秒上限,容纳多次进程交换,不调整其他测试或总套件期限。超时仍走原生进程树清理,并在失败诊断保留进程状态与 stderr;不能通过重试断言、复制前端逻辑或跳过长路径用例来绕开验证。 - 长路径用例先在短路径建立真实仓库,再移入超长目录。Windows CI 已观察到文件系统可访问但创建 Git 进程的工作目录被 `ERROR_DIRECTORY` 拒绝;用例仅接受成功读到原提交,或带原生错误 267 的 `process_start_failed`,不能返回空仓库或访问其他目录。 - Windows 专属测试真实创建末尾点/空格目录及普通同名目录,确认请求明确拒绝,不误用普通目录。 - 原生 watcher 测试用有界事件接收验证外部提交/切换所写的共享引用与独立 HEAD 元数据会通知对应工作树;没有用 sleep 等待防抖。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md index ca2fcc3054..2a9e29bb71 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md +++ b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md @@ -100,6 +100,7 @@ Windows 原来有五个漏洞,叠在一起导致“关闭 Lithe 后服务还 - `./scripts/test-macos.sh --filter '(MacProcessRunnerTests|ExecutionModule)'`。 - `rust/lithe-git-host/tests/process.rs`:`process_tree_terminate_ends_the_root_and_its_descendants` 启动一个会再启动后代的进程,确认 `terminate` 返回时两者都已结束;Windows 上 `dropping_a_process_tree_ends_it_like_a_crashed_host` 只丢弃句柄,确认内核结束整组。 - `windows/tauri/src-tauri/src/run.rs` 测试(仅 Windows):关闭一个窗口只结束它自己的进程树、丢弃会话等同 Lithe 崩溃、原生列表按启动顺序包含全部会话。 + - Windows CI 39919376382 在关闭窗口和丢弃会话两例中,根进程已退出,但立即读取的 `sysinfo` 快照仍包含后代 PID。测试改为停止前打开后代的只等待句柄,停止后分别在两秒内等待根进程和后代的退出信号;另一窗口的后代用零等待确认仍在运行。依据 [Windows 进程终止语义](https://learn.microsoft.com/en-us/windows/win32/procthread/terminating-a-process),退出进程的对象可以因尚未关闭的句柄而继续存在,进程退出信号才是结束依据。保留原来的窗口隔离、重复关闭和崩溃清理断言,不改产品停止流程。夹具的首字节就绪信号有两秒期限,失败路径和断言展开时也按所属会话清理进程树、等待退出并释放管道和句柄;不依赖任意睡眠或 PID 重查。 - `rust/lithe-core/src/lsp/interface/process.rs` 测试(仅 Windows):结束批处理启动的语言服务器时,脚本启动的子进程一起结束。 - `shared/fixtures/process-lifecycle/exit-plan-v1.json`、`settle-v1.json`:运行服务在退出、关闭项目、直接停止设置和崩溃恢复下的动作;macOS `ProcessLifecycleFixtureTests` 用 Swift 类型解码同一批 fixture,并覆盖提问顺序、取消和规则失败时仍询问。 - `windows/tauri/src/features/window/services/run-services-exit.test.ts`:询问、取消、停止失败后重试、直接停止、后台任务不询问、Core 不可用时询问。 diff --git a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md index 69bded6517..a46549b4ed 100644 --- a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md +++ b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md @@ -59,6 +59,7 @@ Git 的多个仓库没有跨仓库原子事务,已成功的提交不会因后 - `rust/lithe-core/src/git/workspace_commit/tests.rs` 覆盖干净祖先联动、关闭联动、独立嵌套仓库、过期计划、失败依赖阻塞、仅重推和外部推进后再次推送。 - `rust/lithe-core/src/tests/git_workspace_commit.rs` 使用真实 Git 验证过期索引拒绝、子模块状态、只更新引用且不带入父文件、首次提交前取消暂存保留后续编辑,暂存后在工作树删除的文件仍可见、子仓库元数据被删除后拒绝回退父仓库,以及共享夹具序列化。真实 Git 还验证共享计划执行、推送失败后的重试、过期步骤重放拒绝、取消与成功提交返回竞争。 - 使用 `write-stable-tests` 的 Rust 逐例计时入口生成 HTML/JUnit;macOS 使用该 Skill 的 macOS 计时入口。 +- Windows CI 39914734587 中,逐仓库推送确认用例在未出现断言失败时达到 15.233 秒进程截止;该用例创建两份分叉仓库及远端,执行三轮准备校验和独立提交/推送。SharedRust 与真实 JDT 两个入口只给这一用例 30 秒预算,默认用例和整套截止不变,真实 Git 进程仍按原有界规则清理,不跳过确认或推送断言。 - 运行共享契约、服务边界、功能矩阵、发行包只读边界和 Agent Notes 检查。 - 当前实现环境是 Linux;本地执行共享 Core 和 Windows 前端测试,原生构建及测试交给对应 CI,macOS/Windows 界面实测仍需平台验收,不将逻辑测试通过记作界面已验证。 diff --git a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md index 8821f32d6b..24225efae9 100644 --- a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md +++ b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md @@ -22,7 +22,7 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、 | 构建、执行、部署 | 构建工具、项目 JDK、运行配置、服务运行,以及框架和构建工具注入的配置 | | 语言与框架 | 语言服务(LSP)的开关,以及 Spring Boot 等框架的配置 | | 工具 | 终端、日志与诊断(查看、目录、清理和诊断包导出)等通用工具;插件带来的专用工具(例如 `.http` 请求)可以放进对应工具页 | -| AI | 凡是和 AI 有关的都放这里:MCP、Skill、AI 提交信息、Agents、AI 服务商 | +| AI | 凡是和 AI 有关的都放这里:MCP、Skill、全局指令、AI 提交信息、Agents、AI 服务商 | 插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。 diff --git a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md index 562d0a2c6e..ae441ed0d5 100644 --- a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md +++ b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md @@ -59,7 +59,110 @@ - env 和 header 值在编辑器中用 `SecureField` 遮蔽,但配置文件中仍是明文;页面明确写出这一点,不制造“已经加密”的错觉。列表摘要及其默认辅助功能文本不显示 stdio 参数,仅显示经过安全字符校验的可执行文件名;HTTP/SSE URL 会移除 userinfo、查询参数和 fragment,无效 URL 仅显示传输方式。例如 `--token=secret` 不能出现在摘要中,`https://user:secret@example.test/mcp?token=secret` 只能显示 `https://example.test/mcp`。 - 错误消息、日志和 Core 返回值都不回显配置内容。解析失败只报告文件路径和错误类型。 -- 首版不自动启动 MCP 服务器做健康检查。启动服务器会执行用户配置的命令,资源库只负责配置,不在用户没有要求时执行项目代码。 +- 打开页面或展开工具区只读;只有点击“检测工具”才连接,stdio 还需确认执行通知。 + 检测会运行配置的命令,可能下载包,因此不能在导入、保存或展开时自动触发。 + +### 紧凑编辑器与工具检测 + +配置弹窗参考 Magpie `ccbc7278180e513bf95461c62eeb15729fc7cae7` 的 +`internal/gui/assets/plugins/library/client.js`;工具排除机制核对其 +`internal/library/mcp_tools_off.go`,并核对 Codex 原生 `disabled_tools` 与 Claude +官方 permissions 文档。独立实现原生页面,不复制 Magpie 的网关和多 Agent 框架。 +命令与参数投影成一行,Rust Core 的 shell-words 1.1.1 只解析/引用 argv,保留空参数、 +内嵌换行和路径空格,不执行 Shell。Agent 用现有品牌图标的选择按钮,帮助仍放在 ⓘ。 + +检测复用仓库已有的官方 rmcp 3.5.0 SDK,初始化、会话、SSE 响应和分页相关语义留给 +SDK;Agent Host 管理执行、取消和进程清理。只增加 client/streamable HTTP 能力, +沿用 reqwest 0.12 的 TLS/网络栈;自定义字节 adapter 在 JSON、错误和 SSE 解码前 +限制 2 MiB,避免 SDK 默认 HTTP JSON 读取无上限。工具最多 1000 项、20 页,重复 +游标拒绝;请求期限 30 秒并有有界清理,stdio 子进程/进程组在结束、失败、取消后回收。 +不发送 tools/call,不处理 OAuth 登录、不读取其他 Agent 的登录令牌;不支持的旧式 +SSE 检测明确报错,仍允许用户编辑原有 SSE 配置。成功只代表这次检测,不能当成现有 +Agent 会话的连接保证。 + +工具区按已注册的 Agent 独立切换。Codex 修改自身条目的 disabled_tools;Claude 修改 +用户 settings.json 的 permissions.deny,只写精确 mcp__规则。外部精确/通配 deny、 +Codex enabled_tools 以及名称规范化冲突保持不可放宽,页面显示只读限制;只移除本库 +登记过的精确禁用项,文件过期时保留外部修改。规则与全局原生文件由现有写入 adapter +备份、条件替换,偏好只登记工具名所有权,不存令牌。Codex 更改规则会改变完整来源 +哈希,页面只推进此前匹配的服务器所有权,不能把一次工具操作当成接管外部服务器。 + +工具规则按原生路径/服务器名保留,即使暂时关闭服务器也保留限制。Codex 删除条目前, +Core 提取原生允许/禁用名单,整批条件写入成功后才把这些工具名保存在原服务器账本的 +`toolFilters` 中;保留记录没有 `sourceHash`,不能授权修改外部重新创建的条目。重新添加 +缺失条目时恢复名单,并根据恢复后的完整内容登记新所有权。现存条目和重命名沿用原生 +规则,不能被旧保留值覆盖;空允许名单仍表示不允许任何工具,格式错误则拒绝删除。 +这份状态随用户资料保留,不保存命令、env、header 或令牌;新页面和应用重启都能恢复。 +Codex 重命名还必须移动相同原生文件路径下、由 Lithe 登记的精确工具名所有权;只搬原生表会让原来可编辑的禁用项被误判为外部限制。迁移在整批文件条件写入成功后执行,失败或恢复不发布新名称所有权;清除旧名称记录,也不合并新名称处可能过期的记录。外部 deny 和 allow-list 不因此取得所有权,其他配置路径和 Agent 保持独立。 +Claude 的规则引用 +服务器名称,原生用户配置存在 MCP 限制时拒绝在本页重命名,避免旧限制未迁移而让 +工具重新可用;用户需先在原生规则中处理。项目/受管规则仍由上游 Agent 决定,页面 +不会显示为已解除全局之外的限制。生命周期退出会取消探测、丢弃迟到结果并释放内存 +快照;这些状态登记在 mcp-tool-probe-state,没有工作树复用阶段或发行目录运行时写入。 + +同一连接新增或移除 Agent 时,服务器行的名称和定义哈希不变,不能只按行 ID 保留旧策略。工具模型记录每行最新来源,来源改变时释放旧策略快照、按现有工具清单重读本地规则;探测尚未返回时也以最新来源读取策略。此刷新不重新连接第三方服务器,保留已检测工具;单个 Agent 的读取失败明确显示错误,其他 Agent 的可用策略保留。 + +检测的 macOS 适配器使用 8 MiB 栈的独立原生线程执行有界 C ABI 调用,避免阻塞 Swift 并发执行器,以及调试版 SDK/TLS 调用在小栈线程上溢出。取消与指令保存共用 `RustCoreCancellation`:Core 登记操作后、执行命令前同步回调,补传注册前到达的取消,不需要重试计时器;页面状态仍拒绝迟到结果。显式设置 `LITHE_MCP_PROBE_INTEGRATION=1` 可运行 Context7 的真实 HTTP 工具列表验证,普通测试不依赖公共网络。 + +### 发现市场与弹窗编辑 + +资源库和“发现 MCP 服务器”在同一页纵向排列,上方管理已添加服务器,下方搜索官方目录;项目级 Lithe 授权在页末。 +添加或编辑使用平铺表单的原生弹窗,只有字段区域滚动,取消和保存固定在底部;资源库超过五项时提供名称与安全摘要搜索。 +发现区标题右侧以灰色普通文字显示“精选,以及官方 MCP Registry”,作为目录来源标记,不提供 Registry 首页直达链接。 +市场卡片直接显示发布者、版本和两行简介,这些是用于选择服务器的目录内容;设置行为和授权解释仍放在 ⓘ。卡片按窗口宽度自动排列为一列或多列。 +标准 `mcpServers` JSON 经既有 Core 解析器生成预览,用户逐个选择条目检查后再保存。 +导入只带入现有编辑器支持的字段,认证与客户端专有字段需另行配置;界面的 ⓘ 明确 +说明这个边界。导入不读环境变量、不写文件,也不接管已有条目。 + +交互参考 Magpie 的 `86254d4049cb6be4a2ccdab02edf0bd59860c603`,目录行为另核对 +`ec7e50b8287462efcb6153fde67308e66b5aaf51` 的 `internal/library/market.go`,尤其 +`internal/gui/assets/plugins/library/client.js` 的发现卡片与编辑流程,以及 +`internal/gui/library.go` 对目录失败的独立提示。只参考公开交互与 API 使用方式, +继续复用 Lithe 的原生控件、配置端口、格式保留与所有权模型,不搬入其 Web UI 或网关。 + +打开页面先展示随版本提供的精选目录,不发网络请求。Magpie 的 `MarketServers` 同样 +先筛选内置 `featured`,只有搜索词非空时才请求 Registry,部分失败仍返回精选结果; +因此“打开时有卡片”不代表该机器已经连通官方目录。Lithe 的精选 JSON 由 Git 管理并 +编译进 Core,`mcp.featuredRegistry` 复用公开目录的校验逻辑返回卡片。包版本从官方 npm +元数据核对后固定,远程地址按发布者文档核对;中文介绍随应用本地化。维护目录时 +必须重新核对包版本、远程地址和文档,不复制 Magpie Image 等产品专属能力。 + +精选包含 Context7、Playwright、Chrome DevTools、Microsoft Learn、GitHub、DeepWiki、 +Exa、Filesystem、Fetch 和 Memory。2026-10-22 核对发布者文档与 npm/PyPI 元数据, +Filesystem/Memory 固定为 2.0.0(需要 Node.js 24+),Fetch 固定为 2026.10.19 +(需要 uv 和 Python 3.10+);GitHub、DeepWiki、Exa 使用发布者的 HTTPS 入口。 +GitHub 的令牌和 Filesystem 的允许目录不能猜测,因此只有版本内的精选可声明 +`setup`,让编辑器显示说明并阻止缺少必填内容的保存。GitHub 预置空 Authorization +请求头,用户填写 Bearer 令牌;Filesystem 必须在包参数后逐行填写至少一个绝对目录, +路径中的空格保留。公开 Registry 不能声明这一可信模板类型,原有复杂输入限制不变。 + +卡片图标使用发布者的品牌标记,不再让所有远程服务都显示地球、所有命令都显示终端。 +图标源文件与来源、审核日期、SHA-256 清单位于 `Resources/McpIcons`,Playwright 的 +Apache-2.0 许可一同保留。它们是 Git 跟踪、SwiftPM 打包的固定输入;运行时只从资源 +bundle 读取并在内存缓存,不访问图片服务器、不下载头像、不写 bundle,因而不改变 +代码签名或 Sparkle delta。三个参考服务器共用 MCP 标记,未认识的条目保留传输方式 +图标。只按可信精选的完整 ID 选择有限文件名,公开条目即便伪装成同名也不使用品牌图标。 +深色页面的品牌标记放在白色小底板上,保留原始颜色和几何;不是工具栏动作图标。 + +搜索时使用官方 `registry.modelcontextprotocol.io/v0.1/servers`,按名称搜索、只查最新 +版本,并使用返回的 opaque cursor(必须原样传回的分页标记)。这是一份目录,不是 +Lithe 的托管服务器或插件市场,也不把收录标成安全审核。Core 的 `mcp.parseRegistry` +把公开元数据转换为可显示的卡片;静态远程地址和没有额外输入的固定版本 npm/PyPI +入口才能生成配置草稿;自定义包源、非默认 runner、npm 可变标签和主/次版本范围都只保留文档。需要密钥、动态地址或复杂参数的记录保留文档入口,不猜命令。 +草稿不默认选择 Agent,用户确认后走原来的保存流程;不会下载、安装或运行服务器。 + +macOS adapter 使用无持久缓存、无 Cookie 和无凭据存储的 URLSession,限制响应为 +1 MiB,请求/总资源期限分别为 15/20 秒。搜索任务归 SwiftUI 页面拥有,离开页面取消; +请求身份防止旧响应覆盖新搜索,分页合并按名称去重,相同 cursor 不再反复加载。 +精选与在线条目按 ID 和不含名称的连接定义去重。连接失败保留已匹配条目,明确区分 +安全连接、超时和网络不可达,并保留手动添加入口,不把失败伪装成“没有服务器”。 +请求默认遵循 macOS 系统代理;只有用户点击“直连重试”才为此页面的公开目录请求 +选择空代理字典的独立 URLSession,继续校验证书,并允许切回系统代理。路由不持久化, +不修改系统、Agent 或实际 MCP 服务器的网络配置,也不自动绕过用户的代理。 + +公开元数据 +的在线响应只保存在进程内存;精选目录属于只读编译输入。两者均无发行目录运行时写入,不影响签名及 Sparkle delta;资源复用 exclusion +将其登记为 `mcp-registry-metadata`,不存在可复制阶段。 ## 考虑过的备选方案 @@ -99,6 +202,13 @@ Windows 的 Agent 配置路径和 Tauri 侧文件访问模型不同,同步实 ## 验证 +- `LITHE_MCP_PROBE_INTEGRATION=1 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --max-seconds 45 -- --filter McpArgumentRoundTripTests`(显式联网验收 Context7 的真实工具列表、原生大栈线程、取消前启动保护、精确参数、独立工具规则和过期文件保护;普通测试不依赖公网) +- `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/Cargo.toml --package lithe-agent-host --report .artifacts/test-stability/mcp-tools-host.json`(本地 stdio、HTTP JSON/SSE 响应、分页、超大响应拒绝、虚拟超时及取消后进程回收) + +- `./scripts/test-macos.sh --filter "McpRegistryTests|McpServerLibraryTests"`:离线首页零网络请求、失败保留精选、显式选择路由和路由间分页隔离、精选与在线连接去重,Registry 桥接、分页、取消、旧请求淘汰,JSON 预览不写文件也不取得所有权;原生卡片浅色/深色下在 420 与 780 点宽度重排,截图写入 `.artifacts/mcp-discovery/` 供人工检查。 +- `LITHE_RUN_MCP_REGISTRY_INTEGRATION=1 ./scripts/test-macos.sh --filter McpRegistryIntegrationTests`:可选联网集成,通过生产 adapter 显式直连官方目录并分页;普通单元测试不依赖公网。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core mcp::registry::`:精选目录无需网络且草稿固定版本,元数据夹具、失效记录、动态输入、危险链接和大小上限。 +- `node --test scripts/test-reuse-worktree-resources.mjs`:进程内目录元数据不得跨 worktree 复用。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core mcp::`(覆盖删除首项/中间项/末项、同行相邻项、值跨多行且下一个键同行的布局、单行与混合换行布局,JSON 输出重新解析,重复 JSON 键拒绝编辑;Codex 更新保留 `bearer_token_env_var`、`startup_timeout_sec`、未知键、建模键与值的注释、未变化的 args 数组内注释和非字符串 env 值,变量列表清空时也保留非字符串值;内联 `mcp_servers` 表可删除、可编辑;拒绝重复变量键;哈希忽略当前传输不写入的 command/args/env/url/headers 且与重解析一致;重命名保留专属字段) - `./scripts/test-macos.sh --filter McpServerLibraryTests`(覆盖计划后磁盘变化拒绝写入、文件在计划后出现拒绝创建、linked worktree 公共排除文件、关闭会话后仍能清理项目注册、项目切换后清理两个项目的注册、Git 排除失败不回滚账本、注销冲突写入错误横幅、重复变量键校验、重命名外部条目、按 Agent 开启时忽略传输不写入的字段、切换传输方式丢弃隐藏字段后仍保持所有权、重命名保留 Codex 字段、自然排序条目的所有权、两个窗口合并账本) - `./scripts/test-macos.sh --filter "McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests"`(备份完成后外部保存被保留并返回 `changedOnDisk`,预检后出现的新文件不被覆盖;`MacFileStorage.replaceData` 拒绝过期快照、按匹配快照原子发布、拒绝覆盖已存在文件;`removeData` 只删除匹配快照的文件;失败/成功路径都不遗留临时文件;临时文件从创建起为 `0600`;回滚换出更新保存或无法恢复时保留并报告路径;不支持交换的卷拒绝写入和删除) diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md new file mode 100644 index 0000000000..623f55f874 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -0,0 +1,102 @@ +# Agent 笔记:技能文件管理与原生 Agent 加载边界 + +状态:已实现 + +## 先说结论 + +技能设置按“全局技能 / 项目技能”组织,Codex 和 Claude 作为列表中的来源标签和筛选项。用户可以查看正文、打开文件、定位目录、新建、导入及启停技能;这些操作使用各 Agent 的原生目录和配置,不维护 Lithe 自己的技能副本或启用状态。技能文件出现在列表中,并不代表当前会话已经加载或执行了它。 + +## 问题 + +输入框的 `$` 和 `/` 补全只能显示当前会话上报的命令,不能回答技能保存在哪里、属于哪个项目、如何添加配套脚本。直接把文件清单当成会话能力,会误导用户;若另存一份技能或增加只影响 UI 的开关,还会出现页面“已关闭”但原生 Agent 仍然读取文件的情况。 + +## 决策 + +### 范围和页面 + +页面放在设置的 AI 分组,Agents 页提供管理入口。主要分类只分全局和项目;项目默认取打开设置的工作区,可选择其他已打开项目,选择动作不切换工作台或会话。新建和导入默认使用正在查看的分类,并明确选择适用 Agent 和显示实际目标目录。 + +「技能」和「指令」是同一 AI 分组中的两个独立页面,必须同时保留入口与各自的功能模型。新增全局指令编辑器不能替换技能设置;预览多个尚未合并的功能时,需要使用包含两者的工作区构建,不能把切到另一开发分支造成的入口缺失误判为原生技能未加载。 + +信息结构参考 PI-Desktop `de57f05aa65b05c93a4113bf5d4db9723884b080` 的 [AgentSkillsPage](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/AgentSkillsPage.tsx) 和 [SkillEditorSheet](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/SkillEditorSheet.tsx)。只参考分类、搜索、紧凑列表与独立编辑面板的交互组织,不复制其代码、样式或状态存储。控件继续使用 Lithe 的设置分段、选择器、搜索框、按钮和菜单。主工作台、标题栏和项目标签不参与此变更。 + +### 发现事实仍由 Agent 拥有 + +Codex 复用已安装 CLI 的官方 `skills/list`,以所选工作区发出强制刷新,保留上游范围、启用状态和插件身份。本机 Codex 0.160.0 与固定适配器依赖的上游 rust-v0.156.1 公开协议均提供这些字段;不需要升级适配器,也不创建会话或发送提示词。生命周期复用既有 App Server 短进程及其有界读取、超时和进程树回收。 + +Claude 固定适配器把 SDK 的 `supportedCommands()` 转成 ACP 命令列表,但缺少可独立查询的完整技能文件路径和范围目录。因此设置页只列出所选项目 `.claude/skills` 和用户配置目录 `skills` 下的文件,并明确显示“目录清单”。用户配置目录服从 `CLAUDE_CONFIG_DIR`,未设置时使用 `~/.claude`。不仿制 Claude 的祖先目录、插件、策略与延迟加载规则;列表 enabled 只表示对应范围的本地配置可见性,不等于原生会话最终可用性。输入框继续仅使用会话实际上报的命令。 + +原生目录依据 [Codex 技能文档](https://developers.openai.com/codex/skills/) 与 [Claude 技能目录说明](https://code.claude.com/docs/en/skills#choose-where-skills-load)。正确示例:在项目分类为 Codex 创建技能,写到该项目 `.agents/skills//SKILL.md`。不要把 Claude 项目目录里的文件改名或移动到全局目录来模拟启停。 + +### 原生启停与配置作用范围 + +纯操作菜单通过共享呈现器保留设置窗口焦点,避免仍开启的原生开关仅因菜单打开而变灰;详见[共享下拉菜单焦点](../simplification/2026-09-30-macos-shared-dropdown-style.md)。每行使用既有原生小开关。点击后立即显示用户选择的状态,后台写入并回读原生配置,不在开关旁增加加载转圈。仅等待确认的技能行禁止重复操作,其他技能保留原生开关的启用状态和颜色;点击其他行时立即显示其选择,并按点击顺序串行执行原生写入与回读,不能并发交换整份清单而覆盖较新的确认。单行失败回滚该行,后续已排队的操作继续执行,错误保留至下一批操作;项目切换或页面离开同时清除未执行队列。不要用页面级忙碌状态禁用全部开关,否则关闭一个技能会让所有仍开启的技能也变灰,误导用户以为它们一起被关闭。用户选择暂存在功能模型,不能提前替换已确认的原生清单或显示“已保存”。确认成功后采用回读结果;失败恢复已确认状态并显示错误,提示刷新核对。未能解析配置时显示“状态未知”,不能把读取失败当作默认启用。项目切换或页面离开会清除临时选择、取消自有请求并拒绝迟到结果,不中断原有 Agent 对话。 + +Codex 使用官方 [skills/config/write](https://developers.openai.com/codex/app-server) 按 SKILL.md 路径设置 enabled。一次有界 App Server 交换先用所选工作区发现技能,拒绝未知路径,再写配置并强制读取列表验证最终状态。Codex 的此接口写入用户配置,因此项目技能的开关也影响所有使用相同路径的工作区;页面提示这一点,不将项目分类误称为独立项目开关。不直接重写 Codex 的 TOML 或插件文件。 + +Claude 使用官方 [skillOverrides](https://code.claude.com/docs/en/skills#override-skill-visibility-from-settings) 的 on/off;固定适配器对应的最低 CLI 2.1.280 已包含该能力,上游 CHANGELOG 从 2.1.129 明确支持。全局开关写 CLAUDE_CONFIG_DIR/settings.json,项目开关写所选项目 .claude/settings.local.json,不修改团队共享 settings.json 或 SKILL.md。该原生配置按名称生效,同名技能共享配置,项目及受管配置可以覆盖全局;界面帮助必须解释这些限制。全局行读取用户配置,项目行合并用户、该项目 settings.json、settings.local.json;这只是所列本地配置的视图,不声称复刻受管、祖先或已连接会话状态。name-only 和 user-invocable-only 仍显示开启,用户关闭再开启会明确改为 on;插件技能需使用 Claude 的插件管理,不通过改名搬目录实现开关。 + +Claude 配置仅合并目标 skillOverrides 键,保留其他 JSON 值与既有权限;无效 JSON、未知枚举、非普通文件、符号链接、超出 1 MiB 的配置均拒绝写入。配置目录内的持久锁文件用非阻塞文件锁串行化 Lithe 实例,写前重新比较原始字节以识别外部修改;临时文件同目录落盘并同步后原子替换,取消或失败回收临时文件。外部编辑器并不共享该锁,字节比较和替换间仍有极短的竞争窗口,不承诺跨所有编辑器的事务。锁与配置由用户目录/所选项目持有,不是可跨 worktree 复用的构建资源,不能写入 app bundle。 + +开关反馈只说明原生配置已保存。成功提示始终预留文字所需的布局空间,仅在原生确认后可见;开始下一次保存时隐藏文字但保留空间,同时从辅助功能树隐藏。不要按保存状态插入或删除整行提示,否则首次保存及连续切换都会让下方技能列表上下跳动。已经加载进会话的内容无法撤回,现有对话应重新连接以刷新命令和技能清单;不自动重连正在执行任务的 Agent。 + +### 文件写入和异步生命周期 + +共享 Core 只负责 `agent.skills` 的类型化分派、取消和错误信封;原生发现与文件操作在 `lithe-agent-host`。Swift 的服务桥离开主线程执行,功能模型保存列表和操作状态,视图只消费模型及显式的原生对话框/打开/定位动作。 + +技能服务与指令服务共用 `RustCoreCancellation`:进入服务和启动后台工作前检查取消,Core 同步发出 `requestStarted` 后补传更早的取消,再分派文件操作。只在取消处理器中发一次 `core.cancel` 不够,因为尚未注册的操作会返回未找到,独立后台任务随后仍可能创建、导入或改配置。功能模型在调用写入服务前也检查取消;安装进度继续通过同一事件回调转发,注册事件不被误当成进度。`AgentSkillsCancellationTests` 在 MainActor 不让出执行权即取消真实桥接任务,并在编码边界触发取消,验证独立项目的技能目录、原生配置和暂存状态均不变,后续合法操作仍能成功。 + +导入保留完整目录中的脚本、模板和文件权限,源目录只读。名称、YAML 文件头、UTF-8、目录深度、文件数和大小都有边界;拒绝导入中的符号链接和特殊文件,以及通过链接的目标容器。先写随机隐藏暂存目录,再以不覆盖模式发布;已有空目录也算冲突。失败或取消回收暂存目录,不改已有技能。查看正文仅返回有界文本,不执行文件中的命令或指令。 + +最终发布必须由操作系统原子地拒绝已有目标,不能只在准备暂存前检查目录是否存在。 +macOS 使用 `RENAME_EXCL`,Linux 使用 `RENAME_NOREPLACE`;Windows 使用 +`windows-sys` 的 `MoveFileExW`,只启用 `MOVEFILE_WRITE_THROUGH`,不启用替换和跨卷复制。 +UTF-16 路径保留 Unicode、空格和长路径前缀,内嵌 NUL 拒绝传入原生 API。 +不能把 Windows 的 `std::fs::rename` 当成不覆盖操作:[Rust 官方文档](https://doc.rust-lang.org/std/fs/fn.rename.html) +说明支持 `FileRenameInfoEx` 的文件系统可替换已有空目录,而 +[MoveFileExW 文档](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw) +提供显式控制替换的标志。Windows CI 曾在这个边界实际覆盖空目标,故回归必须保留失败 +断言;正确做法是让最后一次移动失败并保留源包和已有目标,不是增加另一次易竞争的存在检查。 +未实现排他发布的平台直接拒绝,不退回可能覆盖目标的普通重命名。 + +刷新独立查询两个 Agent,单方不可用不遮住另一方结果;同一项目刷新失败保留旧列表,切项目先清除旧数据。请求与写入捕获项目,迟到结果须核对版本;页面离开时取消自有任务。原生探测的取消必须进入已有交换清理路径,初始化卡住时也先终止并回收子进程,再销毁短生命周期的 Tokio 运行时;不能只丢弃读取 future,把子进程回收留给已经退出的事件循环。保存后刷新列表,是否被运行中的会话发现继续由原生 Agent 决定,不停止会话,也不承诺一次导入会让所有 Agent 都可用。 + +这些文件是用户明确创建或导入的工作资料,不是构建产物、下载缓存或可跨工作树复用资源。它们的生命周期由用户和原生 Agent 管理,写入位置仅为原生用户或所选项目目录;不写应用包、已安装适配器、签名资源或发行目录,不改变代码签名和 Sparkle 增量更新基线。没有新增可复用的构建资源,因此工作树资源清单不登记用户技能。 + +## 考虑过的备选方案 + +- **统一扫描并重建完整 Agent 技能目录**:Codex 已有官方接口,另做一套发现会遗漏插件和策略;Claude 的清单不够完整时应明确说明,而不是复制它的整个解析器。 +- **沿用 PI-Desktop 的独立启用状态和运行时合并**:PI 自己掌握执行器,Lithe 使用外部原生 Agent,自己的布尔值无法证明对方已停用。启停改为写原生配置,不保留独立开关状态;市场、删除或跨范围移动仍不提供。 +- **把所有技能存入 Lithe 数据目录**:会增加原生 CLI 看不到的第二份事实,也让其他工具与 IDE 的结果不一致,因此使用原生技能包目录。 + +## 后果 + +用户能够直接分清全局和项目技能,并通过来源标签理解适用 Agent;管理文件不干扰对话。代价是 Claude 设置清单明确不完整,原生 CLI 不可用时 Codex 的发现会显示错误,技能生效时间仍取决于原生会话。 + +Windows 已能使用共享 Core 与 Host 操作,但设置页面和真实 Windows 文件系统、进程清理尚未验收;功能矩阵保持 partial/pending。组件和受控协议测试不代替真实会话验收。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentSkillsFeatureModelTests|AgentSkillsRustBridgeTests|AgentSkillsAppearanceTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests'` +- `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/Cargo.toml --package lithe-agent-host --report .artifacts/test-stability/agent-skills-rust.json` +- Windows 在原生 CI 中运行相同 Host 测试,覆盖暂存后出现的空目录、普通文件和非空目录都不覆盖,缺失的 Unicode 目标完整发布,以及 NUL 路径不按截断名称发布。这些文件系统用例不等于 Windows 设置页或真实 Agent 会话验收。 +- `./scripts/verify-shared-contracts.sh` +- `./scripts/verify-service-boundaries.sh` +- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-agent-notes.sh` + +原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;开关测试分别阻塞两次原生写入,对比首次点击前、关闭保存中、关闭确认后、再次开启保存中和开启确认后的全部原生开关位置,确保提示变化不移动技能行。可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证点击立即反馈但不提前报告已保存、失败回滚到已确认状态、写入串行和项目切换拒绝旧结果;原生控件回归验证保存期间当前开关立即关闭且不增加加载转圈,其他开启行的原生状态、交互能力与像素外观不变;功能模型控制两次写入的释放次序,验证同一 Agent 的连续保存不丢确认、单项失败后队列继续、重复点击去重,以及切项目/退出时未执行请求不再启动。窗口失去焦点时保留 macOS 原生非活动外观,滑块位置和实际原生配置不随窗口焦点变化。 + +## 适用范围 + +- `rust/lithe-agent-host/src/skills.rs` +- `rust/lithe-agent-host/src/skills/files.rs` +- `rust/lithe-agent-host/src/skills/settings.rs` +- `rust/lithe-core/src/agent/mod.rs` +- `macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift` +- `macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift` +- `macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift` +- `shared/fixtures/agent/skills-management-v1.json` diff --git a/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md new file mode 100644 index 0000000000..2f8fd0deb1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md @@ -0,0 +1,93 @@ +# Agent 笔记:全局指令使用原生用户文件 + +状态:已实现 + +## 先说结论 + +macOS 设置的「AI → 指令」可以编辑 Codex、Claude Code 各自的全局指令。页面同时支持一份共享正文:选择 Agent 后,只把它同步到原生文件中的 Lithe 标记区块,保留用户自有内容。新对话由上游 Agent 加载;Lithe 不额外拼接提示词,也不改项目规则。页面显示实际路径,保存冲突或失败时保留草稿。 + +## 问题 + +Agent 已能继承用户的全局规则,但 Lithe 没有查看和编辑入口。只新增一个文本框并保存到 Lithe 偏好,会让界面显示与 Agent 实际收到的内容不一致;忽略 Codex override 文件,也会出现保存成功却不生效的问题。 + +## 决策 + +- 页面参考 Magpie 的紧凑卡片和 Agent 行:共享正文默认收起,Agent 行显示品牌图标、实际路径、自有非空行数与共享开关,点击展开完整原生编辑器。说明放在 ⓘ,不把长段落放到页面。使用现有设置卡片和原生多行编辑框。Codex、Claude 分别保留内存草稿,切换页面或 Agent 不自动保存。明确选择重新加载并确认放弃草稿后,成功读取才替换草稿。 +- 全局指令和技能管理的入口、功能模型、Core 命令分别保留,在同一 AI 分组中并列可用;预览构建必须包含两个功能后再交给用户。全局指令编辑器不负责加载、启停或复制技能。 +- 原生文件路径由 Agent Host(负责原生文件和进程的 Rust 适配层)解析,使用 Agent 启动时继承的 `CODEX_HOME`、`CLAUDE_CONFIG_DIR` 或平台用户目录。Codex 选择首个非空的 `AGENTS.override.md` / `AGENTS.md`,Claude 选择 `CLAUDE.md`;不支持的自定义 Agent 不猜路径。 +- Core 只分派带类型的请求和稳定错误;Swift 应用模型管理加载、保存、失败和草稿,视图不直接访问文件。原生路径仅作显示和保存校验,不成为跨平台持久标识。 +- 保存比较原文件的路径与摘要(字节内容的 SHA-256 标识),防止覆盖读取后发生的外部修改。临时文件在目标目录内创建,写完同步后原子替换;保留已有权限,拒绝覆盖只读文件,Unix 新文件为 `0600`、新目录为 `0700`。同进程保存使用不等待的锁;其他程序不共享锁,检查与替换之间仍存在很小的外部竞争窗口,不声称提供系统级事务。 +- 清空 override 后重新按原生规则读取,页面可能回到基础 `AGENTS.md`。正确做法是展示这个实际生效的来源;不要保留一个已被原生跳过的空 override 并声称已清空全部指令。 +- 文件只接受至多 1 MiB 的 UTF-8 普通文件,符号链接拒绝编辑。限制用于保护设置页资源;Codex 自身的上下文截断预算仍由原生配置决定,Lithe 不修改它。 +- 仅用户点击保存时写用户配置目录;不写发行 bundle、安装目录、代码签名资源或 Sparkle 增量更新基线。不新增下载、缓存或可跨工作树复用的生成资源,共享正文及同步锁作为用户状态登记到工作树资源排除清单。临时文件由单次保存拥有,成功和失败都清理,异常退出遗留只是一份随机命名临时文件,不会被 Agent 加载。 + +### 共享正文与原生区块 + +用户明确要求共享同步后,增加一份 `Shared.md` 作为已保存共享正文的来源。macOS +组合根通过 `FileStorage.applicationSupportDirectory()` 提供 `Lithe/AgentInstructions/`, +Agent Host 负责原子文件操作,Core 负责严格请求分派,功能模型负责草稿、排队和逐项反馈。 +这是用户配置,不是缓存,不存在版本/平台/架构/工具链身份,也不能跨工作树复制。 +`Shared.md` 和 `.sync.lock` 随用户资料保留;同目录暂存由单次保存拥有,完成或失败清理, +异常退出可能遗留随机命名暂存但 Agent 不读取。目录不从 bundle 或安装目录推导, +因此不影响代码签名与 Sparkle delta。资源复用脚本显式拒绝该用户状态。 + +同步不替换整份规则,而是插入/替换 `LITHE:SHARED-INSTRUCTIONS:BEGIN/END` 注释之间的正文。 +开关由实际文件区块推导,关闭只移除拥有的区块和它自己的换行,原始字节保持。 +非空 Codex override 仍是有效目标;不为了共享功能绕过上游优先级。正文中不能包含标记, +重复、损坏或不完整标记使开关成为未知并提示修复,不猜测删除范围。 + +共享源先保存,再依次更新已开启 Agent。每个文件单独原子替换;这不是多文件事务, +一项权限错误或摘要冲突不能回滚其他已完成项或丢掉共享正文。失败行保留原因, +实际区块与源不同显示待同步,用户显式重试。多个开关按点击顺序串行处理, +只锁定待处理行,其他行不因全局禁用变灰;取消拥有队列的页面任务后不启动后续写入。 +重新进入页面只回读实际状态,不自动同步,也不丢弃未保存草稿。 + +Swift 适配器在启动工作任务前检查取消,并通过 `RustCoreCancellation` 把注册前到达的 +取消保存到同步状态。Core 在登记操作后、分派命令前同步发出 `requestStarted`;回调在这个 +边界补传取消,因此快速保存也不会在注册空隙中漏掉取消。不要只调用一次尚未登记的 +`cancel`,也不要用定时重试保证短写入:写入可能在首次重试前完成。已完成的原子替换 +不会因稍后取消而撤销,跨文件保存仍只在每次写入之间观察取消。 + +正确做法:用户在 Codex 文件写了自有规则,开启共享后两者并存;关闭后自有规则逐字节保留。 +不要把关闭共享解释成关闭 Codex 或删除其整份 AGENTS.md,也不要在启动时自动覆盖外部修改。 + +布局与行为核对 [Magpie 指令页](https://github.com/yetone/magpie/blob/ccbc7278180e513bf95461c62eeb15729fc7cae7/internal/gui/assets/plugins/library/client.js#L1173) +的共享卡片、Agent 行和区块保留方式。本次独立实现,只支持 Lithe 已有原生文件能力的 Codex、Claude Code, +一份共享正文,不引入截图中其他 Agent、多个命名方案、导入私有规则或文件链接。 + +交互结构参考 [PI-Desktop 的全局指令页](https://github.com/vastsa/PI-Desktop/blob/a6f4ba121e524a167c42466c7b4debbb3cfd0744/apps/desktop/src/features/settings/agent-sections.tsx):路径、文本编辑和显式保存。没有复制其源码或视觉样式。原生规则依据 [Codex AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) 和 [Claude Code memory](https://code.claude.com/docs/en/memory)。 + +## 考虑过的备选方案 + +### 把共享正文直接注入每次请求 + +会和原生文件加载产生两份提示词,并需要维护上下文去重和历史恢复语义。因此共享正文通过显式区块同步落到原生用户文件,由上游加载;不修改 Agent 的请求或注入链路。 + +### 保存时停止并重连所有项目 + +会打断正在进行的任务,且不能撤回已进入模型上下文的旧内容。因此只提示新建对话,不自动停止或篡改现有会话。项目规则优先级继续由上游 Agent 决定,不在页面声称全局指令能覆盖所有规则。 + +## 后果 + +用户可以直接管理 CLI 也会读取的全局文件,冲突能保留草稿并显式恢复。代价是符号链接配置需先由用户自行处理,外部编辑不会实时推送到页面,更新也不会改写现有对话。Windows 已可通过共享 Core 调用文件能力,尚无对应设置页和 Windows 实机验证;矩阵保留部分实现、待验证状态。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 +- `SharedInstructionsFeatureTests` 覆盖共享草稿、切换排队、逐项失败与页面取消;Host shared 测试用临时目录验证真实读写、关闭字节还原、override、损坏标记和跨文件部分成功。 +- `AgentInstructionsCancellationTests` 使用真实 Core 和隔离共享源,确定性控制进入适配器前取消、编码后注册前取消,确认文件及锁未创建、已有字节不变,下一次有效保存仍能完成;不写开发者原生指令文件。 +- 典型共享流程前后比较 app bundle 文件清单与哈希,用户目录不参与发行资源写入;`node --test scripts/test-reuse-worktree-resources.mjs` 确认共享指令用户状态不能复制。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentInstructionsFeatureModelTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests|ContextMenuCoverageTests'`。 +- Agent Host 计时测试覆盖原生优先级、读取不创建文件、保存和清空、外部修改与路径变化、UTF-8/大小限制、符号链接拒绝、权限保留和临时文件清理。 +- Core 的 `global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access` 验证共享 fixture 与严格请求边界。 +- 隔离临时用户目录,通过同一 Core 命令保存,再捕获真实 Codex CLI 与 Claude CLI 发出的模型请求;Codex 的保存、更新、override 保存、清空 override、清空基础文件,以及 Claude 的保存、更新、清空共八项均确认新增指令出现、旧指令消失。Codex 五项同时确认项目规则继续存在。响应来自本地受控服务,不是在线模型推理;测试进程和临时目录均回收,不修改用户实际配置。 +- `./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-service-boundaries.sh`、`./scripts/verify-shared-contracts.sh`、`./scripts/verify-agent-notes.sh`、`./scripts/verify-platform-feature-matrix.sh`。 + +## 适用范围 + +- `rust/lithe-agent-host/src/instructions.rs` +- `rust/lithe-agent-host/src/instructions/shared.rs` +- `rust/lithe-core/src/agent/instructions.rs` +- `macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift` +- `shared/fixtures/agent/agent-instructions-v1.json` diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index 6e9ed0e893..9f0f038c39 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -131,6 +131,15 @@ macOS 槽位约十分钟。只改 Rust 时 Swift 测试 job 仍会启动,但 桥接验证,不跑完整单元测试。本地 `scripts/verify-rust-core.sh` 依次运行两半, 入口不变。 +纯 Rust 通道的 Agent Host(Agent 进程和协议宿主)使用计时 runner 默认的 +20 分钟总预算,包含冷编译、测试枚举和执行。运行 `39939156503` 在持续编译 +Agent Host 及 ACP/MCP 协议依赖时,触发了原先的两分钟总限额,测试尚未开始。 +因此不再把轻量 Git Host 的两分钟预算套给 Agent Host;Git Host 仍保留原预算。 +每项 Agent Host 测试仍受 15 秒看门狗约束,整个 CI job 仍限制为 30 分钟。 +代价是异常冷编译可以占用更长时间,不能据此放宽单测试或重试失败断言。 +验证时使用空 Cargo target 目录,并检查报告中的编译耗时和每项测试预算, +不能用本机暖缓存的快速通过代替冷编译证据。 + 插件通道的冷编译与测试执行分别计时。运行 `39741361768` 中,Rust Core 和 Swift 测试编译共用的 20 分钟步骤到期时,Swift 仍在持续编译完整 Lithe 目标,尚未开始 测试;后续 PR 运行复用了这一进行中的任务,因此源任务超时也使后续插件门禁失败。 diff --git a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md index 138124e2aa..3c1d9f5ac8 100644 --- a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md +++ b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md @@ -25,6 +25,16 @@ Project/Dependencies 标题与 Terminal shell/动作入口同样使用 `LitheMen - 产品下拉框直接显示透明面板,`animationBehavior = .none`,不使用 SwiftUI `Menu`、菜单式 `Picker` 或系统 `NSPopover` 的弹跳展开,也不添加缩放或弹簧过渡。新增或修改产品下拉框时必须走共享组件;AI 的强制入口规则位于 `develop-lithe` Skill。 - 调用方只提交条目、动作、可用条件和定位信息。例如 Git Log 设置调用 `show(items:at:appearance:locale:)`;不要重新增加样式布尔值或局部绘制另一套菜单。 +### 操作菜单保留所属窗口的激活外观 + +技能开关暴露了共享动作菜单的焦点问题:打开三个点时菜单成为 key window(接收键盘事件的窗口),设置窗口失去该身份,AppKit 把仍开启的 NSSwitch 画灰。不能用修改技能 enabled、另画开关或强制整页活动状态修饰这个显示问题。 + +对照 Community `c7f91397daa3a961b4e78bc634fe467a0a7d9ade` 的 `platform/platform-impl/src/com/intellij/ui/popup/AbstractPopup.java`:它区分弹窗是否能够聚焦与是否主动请求焦点(myFocusable/myRequestFocus)。Lithe 的纯操作菜单没有文本编辑器,在所属窗口已经接收键盘事件时保留其 key 身份,自己以不可成为 key 的子面板显示;由共享事件监听把该窗口的方向键、Return 和 Esc 交给菜单。无法确认有效所属窗口时保留旧的聚焦回退。可搜索的自定义下拉仍自己接收焦点,输入法和搜索字段路径不改。 + +若操作菜单来自另一个可搜索面板,父监听必须先让子菜单处理按键,避免一次方向键被父搜索列表抢走。未处理的输入或快捷键先关闭动作菜单再传回父窗口。所属窗口失焦或应用切走时关闭动作菜单;关闭、锚点拆除及失败清理时移除所有事件和通知监听。原生开关仍由真实 isEnabled 决定忙碌禁用,菜单不触发任何技能配置写入。 + +`AgentSkillsAppearanceTests` 检查保留父窗口焦点、原生开关值/可用性与绘制不变,以及真正保存期间仍被禁用。`LitheActionMenuFocusTests` 检查键盘和原响应者、嵌套面板分派、Esc、失焦和应用切换清理;仍需真实应用打开菜单核对颜色,后台测试进程的灰色基线不能单独证明实际视觉已修复。 + Monaco 的原生菜单选中经过异步 WebKit 消息回调,已经失去浏览器的用户手势授权。 复制、剪切、粘贴因此由 WKWebView 原生 responder 命令执行,继续触发 Monaco 自己的 DOM 剪贴板监听和撤销逻辑;返回 `handled` 后前端不能再次执行动作。 diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index effcefd94c..60e2d8d72d 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -43,6 +43,14 @@ native Git process/AskPass tests in `git-host-rust.json` and Core tests in `shared-rust.json`. Testing a Cargo package does not run its dependencies' own tests. macOS's `verify-rust-core.sh` runs the same native adapter timing lane. +In macOS's Rust-only lane, Agent Host uses the Rust runner's default 20-minute +suite budget, including compilation. Run 39939156503 exhausted the old two-minute +override while still compiling Agent Host and its ACP/MCP dependencies; no test +had started. Git Host keeps its smaller two-minute suite allowance. Agent Host's +individual cases retain the default 15-second watchdog, and the CI job keeps its +30-minute limit. A warm-cache pass does not verify this path: use an empty Cargo +target directory and inspect both compilation time and per-case report budgets. + Every Bun and Rust lane writes JUnit XML plus a self-contained HTML dashboard below `.artifacts/test-stability/`. The dashboard groups Rust cases by crate module and Bun cases by their JUnit class or suite. @@ -79,7 +87,13 @@ Each patch request still has its own 15-second deadline, and the enclosing suite deadline remains unchanged. Both SharedRust and the real-JDT lane use these narrowly scoped budgets. Core rebase requests use a nested 20-second deadline. The workspace commit submodule publication test also gets 30 seconds -because it creates two local remotes and runs multiple real Git pushes. Other +because it creates two local remotes and runs multiple real Git pushes. +`tests::git_workspace_commit::git_workspace_commit_push_confirms_each_repository_separately` +also gets 30 seconds in both lanes after run 39914734587 killed it at 15.233 +seconds without an assertion failure. It prepares two divergent local +repositories/remotes, checks independent confirmations in three preparation +passes, and commits/pushes each repository. The default case and suite deadlines +remain unchanged; bounded native subprocess calls and assertions still run. Other multi-repository coverage includes `tests::git::git_write_updates_a_noncurrent_branch_without_switching_head`, which also gets 30 seconds in both SharedRust and real-JDT lanes: it builds a @@ -101,6 +115,29 @@ HTML/JUnit classification, and remains capped by the shared suite deadline. Use scoped budgets only for measured integration costs, never to bypass an unbounded wait or an assertion failure. +`git_paths_roundtrip_` imports the production TypeScript path normalizer in +real Node subprocesses between native Git/Core requests. Windows run 39933419913 +cancelled Node at the fixture's 10-second subprocess deadline in the long-path +case; no path-result mismatch was reported. Only Node's Windows allowance is +20 seconds; Git setup stays at 10 seconds and Core requests at 5 seconds. Both +SharedRust and real-JDT give these path roundtrip cases a 60-second case budget +for the repeated subprocess exchanges. Other cases keep their default budget, +the suite deadline is unchanged, and cancellation still reclaims the process +tree through the native Git Host. Do not replace the frontend import with a +Rust copy of its normalization logic or retry a failed assertion. +The prefix matches the raw Rust test name, without the Cargo target printed +before it in the console. Check each report's `maxMs` to confirm the allowance +actually applies; a successful fast run alone does not prove prefix matching. + +`tests::git::git_write_executes_checkout_preflight_clone_and_validation` +gets 30 seconds in both Windows lanes. Run 39935852248 reported its assertions +passing in 15.22 seconds, but the complete test process took 18.529 seconds and +exceeded the default 15-second budget. It creates divergent branches, checks +clean/dirty/untracked checkout preflights, performs smart and forced checkouts, +and clones a real local repository. The cache retry then exhausted the existing +suite deadline; that secondary timeout does not justify extending the whole +suite. Keep every checkout/clone assertion and the existing request deadlines. + ## Windows verification Run the PowerShell harness in a real Windows environment. A macOS boundary diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index cfa7b41875..b644ec84f7 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -69,16 +69,21 @@ function Invoke-TimedRustTests { $arguments += @("--package", $Package) } if ($Package -eq "lithe-core") { + # These cases start real Git and the production TypeScript module + # in Node. One Node startup hit its old 10s local deadline on CI. + $arguments += @("--test-budget", "git_paths_roundtrip_=60000") # Integration tests that create multiple real repositories or push # through Git subprocesses need a bounded allowance on Windows. foreach ($prefix in @( "tests::git_history_rewrite::", "tests::git_remote_checkout::", "tests::git_workspace_commit::git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote", + "tests::git_workspace_commit::git_workspace_commit_push_confirms_each_repository_separately", "tests::git::git_write_squashes_", "tests::git::git_write_deletes_a_local_commit_", "tests::git::git_write_edits_a_local_commit_message_", "tests::git::git_write_updates_a_noncurrent_branch_without_switching_head", + "tests::git::git_write_executes_checkout_preflight_clone_and_validation", "tests::git_patch_exchange::patch_exchange_detects_index_flags_and_hidden_destination_edits", "tests::git_patch_exchange::patch_exchange_rejects_stale_preview_and_forward_conflicts_without_writing", "tests::git_patch_exchange::patch_exchange_roundtrips_renames_deletes_and_platform_file_modes", diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 99db6ed2db..ae6d34bbf1 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -779,15 +779,18 @@ jobs: --package lithe-core ` --warn-ms 1000 ` --max-ms 15000 ` + --test-budget git_paths_roundtrip_=60000 ` --test-budget lsp::interface::engine::real_jdt_tests::real_jdtls_discovers_builds_and_launches_java_25_entrypoints=600000 ` --test-budget tests::git_history_rewrite::=30000 ` --test-budget tests::git_history_rewrite::native_rebase_skip_omits_conflicted_message_from_later_squash=45000 ` --test-budget tests::git_remote_checkout::=30000 ` --test-budget tests::git_workspace_commit::git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote=30000 ` + --test-budget tests::git_workspace_commit::git_workspace_commit_push_confirms_each_repository_separately=30000 ` --test-budget tests::git::git_write_squashes_=30000 ` --test-budget tests::git::git_write_deletes_a_local_commit_=30000 ` --test-budget tests::git::git_write_edits_a_local_commit_message_=30000 ` --test-budget tests::git::git_write_updates_a_noncurrent_branch_without_switching_head=30000 ` + --test-budget tests::git::git_write_executes_checkout_preflight_clone_and_validation=30000 ` --test-budget tests::git_patch_exchange::patch_exchange_detects_index_flags_and_hidden_destination_edits=30000 ` --test-budget tests::git_patch_exchange::patch_exchange_rejects_stale_preview_and_forward_conflicts_without_writing=30000 ` --test-budget tests::git_patch_exchange::patch_exchange_roundtrips_renames_deletes_and_platform_file_modes=30000 ` diff --git a/Package.swift b/Package.swift index c43df490cb..dfa5cec9e4 100644 --- a/Package.swift +++ b/Package.swift @@ -122,6 +122,7 @@ let package = Package( path: "macos/Sources/Lithe", resources: [ .copy("Resources/AgentIcons"), + .copy("Resources/McpIcons"), .copy("Resources/MarkdownPreview"), .copy("Resources/SyntaxHighlighting"), .copy("Resources/WorkbenchBackgrounds") diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 693bd69e83..c11bc8743c 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -357,6 +357,42 @@ Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存。先 ### 独立工作树的本地编译 +共享指令的 `Lithe/AgentInstructions/Shared.md`、`.sync.lock` 和同目录原子保存暂存文件登记为 +`agent-instruction-library-state`,由平台 storage adapter 的 Application Support 根目录提供。 +它们是可变用户配置,不具有版本/平台/架构/工具链身份;所有工作树复用阶段均拒绝复制, +不从其他工作树或发行包导入。原生 Agent 指令文件同样保留用户归属;暂存文件随保存完成或失败清理, +空锁文件属于用户资料的生命周期。运行时不写 app bundle、安装目录或 Sparkle delta 基线。 + +MCP 工具检测会话、工具描述、原生规则快照和工具排除所有权登记为 `mcp-tool-probe-state`。 +这些内容属于用户资料和一次页面操作,没有版本/平台/架构/工具链 identity stamp,所有复用阶段均拒绝复制。 +官方 SDK 沿用 Cargo 锁定版本构建,stdio/HTTP 检测只在用户主动操作时运行; +HTTP/SSE 响应体和 stdio 元数据限制为 2 MiB,原生文件由现有平台 adapter 条件替换并备份到 Application Support。 +工具名称所有权保存在平台偏好中,不存令牌;Codex 移除服务器后,允许/禁用工具名保留在 +同一用户资料的服务器账本 `toolFilters`,重新添加时恢复,不作为活动文件的所有权证明。 +这些保留值随资料持久化,不能复制到其他工作树;取消/离页释放进程与内存,不写 app bundle 或安装目录。 +用户明确运行包命令可能产生的 npm/uv 缓存归上游运行时管理,不属于可跨工作树复用的 Lithe 构建资源。 + +MCP 编辑器、工具面板和指令页的原生验收截图分别输出到 +`.artifacts/test-stability/mcp-ui/` 与 `.artifacts/test-stability/instructions-ui/`,随 +`test-stability-macos` artifact 提供给评审下载。截图只渲染隔离 fixture 的实际产品视图, +不读取桌面或真实用户配置;按 `agent-ui-test-evidence` 排除于所有工作树资源复制阶段, +本地随报告保留,CI 按 artifact retention 回收,不进入发行包。 + +MCP 发现页读取的官方 Registry 在线响应登记为 `mcp-registry-metadata`,仅在当前进程内存中保存。 +响应最多 1 MiB,由 Rust Core 校验;按搜索、分页和取消请求管理生命周期,没有持久缓存、 +平台/架构/工具链身份或允许复制的阶段。资源复用脚本明确拒绝该项,不下载服务器包或图片, +也不写入 app bundle 或 Windows 安装目录,因此不改变签名和 Sparkle delta 基线。 +内置精选 `rust/lithe-core/src/mcp/featured.json` 属于 Git 跟踪的编译输入,随本工作树 +源码编译进 Core,运行时只读;不从缓存或其他工作树复制,不登记为下载/生成资源。 +查询路由默认使用系统代理,显式直连只改变当前页面的内存请求,不产生可复用状态。 + +精选品牌图标 `macos/Sources/Lithe/Resources/McpIcons` 也是 Git 跟踪的固定输入, +`NOTICE.json` 记录每个来源与 SHA-256,随 SwiftPM 资源 bundle 在构建时打包。 +该目录与精选 JSON 一起归入 `mcp-registry-metadata` 的不可复用说明:没有下载缓存、 +生成 identity stamp 或跨工作树复制阶段;版本由当前 Git 源码和清单哈希决定, +不从其他工作树或已安装应用提取。运行时只读并在内存缓存解码结果,不访问图标网络, +不改写签名包或 Sparkle delta 基线;Windows 未消费此 macOS 资源目录。 + Cargo 社区插件在 [独立仓库](https://github.com/Lithe-IDEA/lithe-plugin-cargo) 中构建, 最终 ZIP、摘要、vendored Node SDK 和集成测试产物由该仓库的 `dist/`、`.artifacts/` 持有, 不作为 Lithe 主仓库的下载缓存或可复用资源。Cargo 专属清单、模型金样与兼容性测试也由 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index d1909c2483..b943257e40 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2139,3 +2139,223 @@ /* Embedded HTML preview browser: context menu, print and PDF. */ "Print…" = "Print…"; "Export as PDF…" = "Export as PDF…"; +"Instructions" = "Instructions"; +"Global instructions" = "Global instructions"; +"Instruction Agent" = "Instruction Agent"; +"Global instructions apply to all projects using this Agent. Each Agent keeps its own native file." = "Global instructions apply to all projects using this Agent. Each Agent keeps its own native file."; +"The file will be created when you save." = "The file will be created when you save."; +"Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions." = "Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions."; +"Save instructions" = "Save instructions"; +"Reload from disk" = "Reload from disk"; +"Instructions saved." = "Instructions saved."; +"Unsaved changes" = "Unsaved changes"; +"Start a new conversation after saving to load the updated instructions. Project instructions still apply." = "Start a new conversation after saving to load the updated instructions. Project instructions still apply."; +"Discard draft and reload?" = "Discard draft and reload?"; +"Reloading replaces this draft with the current file contents." = "Reloading replaces this draft with the current file contents."; +"Global instructions must be no larger than 1 MiB." = "Global instructions must be no larger than 1 MiB."; +"Cannot access the global instruction file. Check its permissions and try again." = "Cannot access the global instruction file. Check its permissions and try again."; +"Global instructions are available for Codex and Claude Code." = "Global instructions are available for Codex and Claude Code."; +"Cannot locate the Agent user directory." = "Cannot locate the Agent user directory."; +"The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR." = "The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR."; +"The instruction path must be a regular file, not a symbolic link or directory." = "The instruction path must be a regular file, not a symbolic link or directory."; +"The global instruction file must use UTF-8 encoding." = "The global instruction file must use UTF-8 encoding."; +"Another instruction save is running. Try again." = "Another instruction save is running. Try again."; +"The instruction directory changed. Reload before saving." = "The instruction directory changed. Reload before saving."; +"Global instructions changed outside this editor. Your draft is kept; reload before saving." = "Global instructions changed outside this editor. Your draft is kept; reload before saving."; +/* Agent skill management */ +"Skills" = "Skills"; +"Agent skills" = "Agent skills"; +"Global skills" = "Global skills"; +"Project skills" = "Project skills"; +"Manage Skills…" = "Manage Skills…"; +"Search skills" = "Search skills"; +"Skill files" = "Skill files"; +"All sources" = "All sources"; +"Skill source" = "Skill source"; +"Skill project" = "Skill project"; +"Skill actions" = "Skill actions"; +"Refresh skills" = "Refresh skills"; +"New skill" = "New skill"; +"Import skill" = "Import skill"; +"Import SKILL.md" = "Import SKILL.md"; +"Import skill directory" = "Import skill directory"; +"View skill" = "View skill"; +"Skill target Agent" = "Skill target Agent"; +"Copy path" = "Copy path"; +"Open SKILL.md" = "Open SKILL.md"; +"Reveal in Finder" = "Reveal in Finder"; +"No skills in this scope." = "No skills in this scope."; +"No matching skills." = "No matching skills."; +"Open a project to manage its skills." = "Open a project to manage its skills."; +"Enabled in Agent" = "Enabled in Agent"; +"Disabled in Agent" = "Disabled in Agent"; +"Built-in" = "Built-in"; +"Managed" = "Managed"; +"Plugin" = "Plugin"; +"Import source" = "Import source"; +"Create skill" = "Create skill"; +"Skill instructions" = "Skill instructions"; +"Skill name (lowercase-with-hyphens)" = "Skill name (lowercase-with-hyphens)"; +"When should this skill be used?" = "When should this skill be used?"; +"Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation." = "Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation."; +"Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions." = "Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions."; +"File inventory only. Claude determines whether this skill is available in a session." = "File inventory only. Claude determines whether this skill is available in a session."; +"Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten." = "Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten."; +"Install Codex CLI to discover its skills." = "Install Codex CLI to discover its skills."; +"Codex skill discovery failed. Check the CLI and retry." = "Codex skill discovery failed. Check the CLI and retry."; +"The skill file could not be opened." = "The skill file could not be opened."; +"The skill file could not be read." = "The skill file could not be read."; +"The skill document is unavailable. Refresh and retry." = "The skill document is unavailable. Refresh and retry."; +"SKILL.md must use UTF-8 text." = "SKILL.md must use UTF-8 text."; +"SKILL.md needs YAML frontmatter." = "SKILL.md needs YAML frontmatter."; +"SKILL.md has invalid YAML frontmatter." = "SKILL.md has invalid YAML frontmatter."; +"SKILL.md needs a name and description." = "SKILL.md needs a name and description."; +"SKILL.md frontmatter is not closed." = "SKILL.md frontmatter is not closed."; +"Enter a description and instructions." = "Enter a description and instructions."; +"Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "Use up to 64 lowercase letters, digits and single hyphens for the skill name."; +"Select SKILL.md or its containing directory." = "Select SKILL.md or its containing directory."; +"A skill with this directory name already exists. Existing skills are never overwritten." = "A skill with this directory name already exists. Existing skills are never overwritten."; +"Skill imports cannot contain symbolic links or special files." = "Skill imports cannot contain symbolic links or special files."; +"Import a regular file or directory instead of a symbolic link." = "Import a regular file or directory instead of a symbolic link."; +"The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory." = "The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory."; +"The skill could not be published. Check permissions and existing skill names." = "The skill could not be published. Check permissions and existing skill names."; +"Codex reported skill discovery errors. Check the affected SKILL.md files and refresh." = "Codex reported skill discovery errors. Check the affected SKILL.md files and refresh."; +"A skill was omitted because its native metadata is incomplete." = "A skill was omitted because its native metadata is incomplete."; +"A skill has an unknown scope and cannot be classified." = "A skill has an unknown scope and cannot be classified."; +"That skill name is reserved by Claude. Choose a different name." = "That skill name is reserved by Claude. Choose a different name."; +"SKILL.md exceeds the 128 KiB limit." = "SKILL.md exceeds the 128 KiB limit."; +"SKILL.md needs instructions after its frontmatter." = "SKILL.md needs instructions after its frontmatter."; + +"Enable skill" = "Enable skill"; + +"State unknown" = "State unknown"; + +"Switches save native Agent settings. Reconnect existing conversations to refresh available skills." = "Switches save native Agent settings. Reconnect existing conversations to refresh available skills."; + +"Skill setting saved." = "Skill setting saved."; + +"Codex saves this setting by path for all projects using this skill." = "Codex saves this setting by path for all projects using this skill."; + +"Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting." = "Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting."; + +"Skill settings could not be updated. Check the settings file and refresh." = "Skill settings could not be updated. Check the settings file and refresh."; + +"Claude skill settings could not be read. Fix invalid settings and refresh before toggling." = "Claude skill settings could not be read. Fix invalid settings and refresh before toggling."; + +"Choose whether to enable the skill." = "Choose whether to enable the skill."; + +"Codex skill settings could not be confirmed. Refresh to check the current state." = "Codex skill settings could not be confirmed. Refresh to check the current state."; + +"The Agent did not apply the skill setting. Refresh and check project or managed configuration." = "The Agent did not apply the skill setting. Refresh and check project or managed configuration."; + +"The skill is no longer available in this scope. Refresh and retry." = "The skill is no longer available in this scope. Refresh and retry."; + +"Claude plugin skills must be managed through Claude's plugin settings." = "Claude plugin skills must be managed through Claude's plugin settings."; + +"Skill settings are busy. Retry after the other change finishes." = "Skill settings are busy. Retry after the other change finishes."; + +"Skill settings changed elsewhere. Refresh and retry." = "Skill settings changed elsewhere. Refresh and retry."; + +/* MCP discovery and import */ +"Discover MCP servers" = "Discover MCP servers"; +"Search MCP servers" = "Search MCP servers"; +"Import JSON" = "Import JSON"; +"Search the official MCP Registry" = "Search the official MCP Registry"; +"Official MCP Registry" = "Official MCP Registry"; +"Featured and the official MCP Registry" = "Featured and the official MCP Registry"; +"Registry website" = "Registry website"; +"No matching MCP servers" = "No matching MCP servers"; +"Documentation" = "Documentation"; +"Configure…" = "Configure…"; +"Load more" = "Load more"; +"Import MCP configuration" = "Import MCP configuration"; +"MCP JSON" = "MCP JSON"; +"Review configuration" = "Review configuration"; +"Preview import" = "Preview import"; +"New MCP server" = "New MCP server"; +"Edit MCP server" = "Edit MCP server"; +"The MCP Registry could not be loaded. Try again or add a server manually." = "The MCP Registry could not be loaded. Try again or add a server manually."; +"Paste a JSON object containing mcpServers." = "Paste a JSON object containing mcpServers."; +"Could not import MCP JSON. Check the mcpServers object and try again." = "Could not import MCP JSON. Check the mcpServers object and try again."; +"Public directory metadata is supplied by the official MCP Registry. Listing is not an endorsement or a security review. Adding opens an editable configuration; Lithe does not download or run server packages. Servers with complex inputs must be configured using the publisher's documentation." = "Public directory metadata is supplied by the official MCP Registry. Listing is not an endorsement or a security review. Adding opens an editable configuration; Lithe does not download or run server packages. Servers with complex inputs must be configured using the publisher's documentation."; +"Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry." = "Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry."; + +"Featured" = "Featured"; +"The featured MCP catalog could not be loaded." = "The featured MCP catalog could not be loaded."; +"A secure connection to the MCP Registry could not be established. Check the proxy or retry directly." = "A secure connection to the MCP Registry could not be established. Check the proxy or retry directly."; +"The MCP Registry request timed out. Try again." = "The MCP Registry request timed out. Try again."; +"The MCP Registry is unreachable. Check the network or proxy." = "The MCP Registry is unreachable. Check the network or proxy."; +"Search MCP servers — GitHub, browsers, documentation…" = "Search MCP servers — GitHub, browsers, documentation…"; +"Featured servers ship with Lithe and work offline. Searching also checks the official MCP Registry. Listing is not a security review. Adding opens a configuration to review; packages are not downloaded or run. Follow the publisher's documentation for authentication and other requirements." = "Featured servers ship with Lithe and work offline. Searching also checks the official MCP Registry. Listing is not a security review. Adding opens a configuration to review; packages are not downloaded or run. Follow the publisher's documentation for authentication and other requirements."; +"Direct connection" = "Direct connection"; +"Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled." = "Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled."; +"Use system proxy" = "Use system proxy"; +"Showing available matches." = "Showing available matches."; +"Retry directly" = "Retry directly"; +"Current library documentation and code examples for coding agents." = "Current library documentation and code examples for coding agents."; +"Open pages, click, type and inspect a browser through its accessibility tree." = "Open pages, click, type and inspect a browser through its accessibility tree."; +"Inspect Chrome pages, console messages, network requests and performance." = "Inspect Chrome pages, console messages, network requests and performance."; +"Search official Microsoft and Azure documentation and code samples." = "Search official Microsoft and Azure documentation and code samples."; + +/* Shared instruction library */ +"Shared instructions" = "Shared instructions"; +"Default instructions" = "Default instructions"; +"Empty" = "Empty"; +"Save shared instructions" = "Save shared instructions"; +"Shared instructions saved." = "Shared instructions saved."; +"Use shared instructions" = "Use shared instructions"; +"Sync needed" = "Sync needed"; +"Sync again" = "Sync again"; +"%d lines of its own" = "%d lines of its own"; +"Native instruction file" = "Native instruction file"; +"Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply." = "Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply."; +"The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; +"This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents." = "This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents."; +"The shared instruction directory must be absolute." = "The shared instruction directory must be absolute."; +"Shared instructions changed outside this editor. Reload before saving." = "Shared instructions changed outside this editor. Reload before saving."; +"Invalid shared instruction request." = "Invalid shared instruction request."; +"Instruction synchronization was cancelled." = "Instruction synchronization was cancelled."; +"Reload this Agent before synchronizing instructions." = "Reload this Agent before synchronizing instructions."; +"The Lithe shared instruction markers were changed. Repair the native file before syncing." = "The Lithe shared instruction markers were changed. Repair the native file before syncing."; +"Shared instructions cannot contain Lithe synchronization markers." = "Shared instructions cannot contain Lithe synchronization markers."; +"Instruction Agents" = "Agents"; + +/* Featured MCP publishers and setup */ +"Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required." = "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required."; +"Ask questions about public GitHub repositories and read their generated documentation." = "Ask questions about public GitHub repositories and read their generated documentation."; +"Search the web and code, and retrieve page content for coding agents." = "Search the web and code, and retrieve page content for coding agents."; +"Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "Read, write and search files only in the directories you allow. Requires Node.js 24 or later."; +"Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later." = "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later."; +"Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later." = "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later."; +"GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file." = "GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file."; +"Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later."; +"Enter an Authorization header with Bearer and your GitHub token." = "Enter an Authorization header with Bearer and your GitHub token."; +"Add at least one absolute allowed folder path after the Filesystem package argument." = "Add at least one absolute allowed folder path after the Filesystem package argument."; + +/* MCP native tools inspection and compact editor */ +"Server tools" = "Server tools"; +"Detecting…" = "Detecting…"; +"%d tools" = "%d tools"; +"Not detected" = "Not detected"; +"Detect again" = "Detect again"; +"Detect tools" = "Detect tools"; +"Reload tool settings" = "Reload tool settings"; +"This server reported no tools." = "This server reported no tools."; +"Run this MCP command?" = "Run this MCP command?"; +"Run and detect" = "Run and detect"; +"Remove from library" = "Remove from library"; +"Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process." = "Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process."; +"Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes." = "Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes."; +"This tool is restricted outside Lithe. Edit the native Agent policy to enable it." = "This tool is restricted outside Lithe. Edit the native Agent policy to enable it."; +"Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated." = "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated."; +"Could not read the command line." = "Could not read the command line."; +"Check command-line quotes before saving." = "Check command-line quotes before saving."; +"Check the MCP command, URL and headers." = "Check the MCP command, URL and headers."; +"Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint." = "Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint."; +"Could not start the MCP command. Check its executable and runtime." = "Could not start the MCP command. Check its executable and runtime."; +"MCP connection failed. Check the server, network and authentication." = "MCP connection failed. Check the server, network and authentication."; +"MCP tool metadata exceeded the inspection limit." = "MCP tool metadata exceeded the inspection limit."; +"MCP detection timed out after 30 seconds." = "MCP detection timed out after 30 seconds."; +"MCP detection was cancelled." = "MCP detection was cancelled."; +"Invalid MCP tool configuration" = "Invalid MCP tool configuration"; +"Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name." = "Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index b3010fd514..41c899e7d3 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3716,3 +3716,220 @@ /* 内嵌 HTML 预览浏览器:右键菜单、打印和 PDF。 */ "Print…" = "打印…"; "Export as PDF…" = "导出为 PDF…"; +"Instructions" = "指令"; +"Global instructions" = "全局指令"; +"Instruction Agent" = "指令所属 Agent"; +"Global instructions apply to all projects using this Agent. Each Agent keeps its own native file." = "全局指令适用于使用此 Agent 的所有项目。每个 Agent 使用各自的原生文件。"; +"The file will be created when you save." = "保存时将创建此文件。"; +"Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions." = "Codex 正在使用 AGENTS.override.md。清空后将恢复基础 AGENTS.md 中的指令。"; +"Save instructions" = "保存指令"; +"Reload from disk" = "从磁盘重新加载"; +"Instructions saved." = "指令已保存。"; +"Unsaved changes" = "有未保存的修改"; +"Start a new conversation after saving to load the updated instructions. Project instructions still apply." = "保存后请新建对话以加载更新后的指令。项目指令仍然有效。"; +"Discard draft and reload?" = "放弃草稿并重新加载?"; +"Reloading replaces this draft with the current file contents." = "重新加载将使用磁盘文件的当前内容替换此草稿。"; +"Global instructions must be no larger than 1 MiB." = "全局指令不能超过 1 MiB。"; +"Cannot access the global instruction file. Check its permissions and try again." = "无法访问全局指令文件,请检查文件权限后重试。"; +"Global instructions are available for Codex and Claude Code." = "Codex 和 Claude Code 支持全局指令。"; +"Cannot locate the Agent user directory." = "无法定位 Agent 的用户目录。"; +"The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR." = "Agent 用户目录必须为绝对路径,请检查 CODEX_HOME 或 CLAUDE_CONFIG_DIR。"; +"The instruction path must be a regular file, not a symbolic link or directory." = "指令路径必须为普通文件,不能为符号链接或目录。"; +"The global instruction file must use UTF-8 encoding." = "全局指令文件必须使用 UTF-8 编码。"; +"Another instruction save is running. Try again." = "另一项指令保存正在进行,请重试。"; +"The instruction directory changed. Reload before saving." = "指令目录已变更,请重新加载后再保存。"; +"Global instructions changed outside this editor. Your draft is kept; reload before saving." = "全局指令已被其他程序修改。草稿已保留,请重新加载后再保存。"; +/* Agent skill management */ +"Skills" = "技能"; +"Agent skills" = "Agent 技能"; +"Global skills" = "全局技能"; +"Project skills" = "项目技能"; +"Manage Skills…" = "管理技能…"; +"Search skills" = "搜索技能"; +"Skill files" = "技能文件"; +"All sources" = "全部来源"; +"Skill source" = "技能来源"; +"Skill project" = "当前项目"; +"Skill actions" = "技能操作"; +"Refresh skills" = "刷新技能"; +"New skill" = "新建技能"; +"Import skill" = "导入技能"; +"Import SKILL.md" = "导入 SKILL.md"; +"Import skill directory" = "导入技能目录"; +"View skill" = "查看"; +"Skill target Agent" = "适用 Agent"; +"Copy path" = "复制路径"; +"Open SKILL.md" = "打开 SKILL.md"; +"No skills in this scope." = "当前分类暂无技能。"; +"No matching skills." = "没有匹配的技能。"; +"Open a project to manage its skills." = "请先打开项目,再管理项目技能。"; +"Enabled in Agent" = "Agent 已启用"; +"Disabled in Agent" = "Agent 已禁用"; +"Built-in" = "内置"; +"Managed" = "托管"; +"Plugin" = "插件"; +"Import source" = "导入来源"; +"Create skill" = "创建技能"; +"Skill instructions" = "技能正文"; +"Skill name (lowercase-with-hyphens)" = "技能名称(小写字母与连字符)"; +"When should this skill be used?" = "什么情况下应使用这个技能?"; +"Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation." = "技能已保存。如果对话中未显示,请刷新或重新连接 Agent。"; +"Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions." = "Codex 显示官方发现的技能;Claude 显示所选项目和用户目录中的技能文件。Claude 会话还可能包含继承目录和插件提供的技能。"; +"File inventory only. Claude determines whether this skill is available in a session." = "此处仅列出技能文件。技能是否在会话中可用,由 Claude 决定。"; +"Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten." = "导入目录会保留脚本、模板等配套文件,不会覆盖已有技能。"; +"Install Codex CLI to discover its skills." = "请先安装 Codex CLI,再查看它发现的技能。"; +"Codex skill discovery failed. Check the CLI and retry." = "Codex 技能发现失败,请检查 CLI 后重试。"; +"The skill file could not be opened." = "无法打开技能文件。"; +"The skill file could not be read." = "无法读取技能文件。"; +"The skill document is unavailable. Refresh and retry." = "技能文件当前不可用,请刷新后重试。"; +"SKILL.md must use UTF-8 text." = "SKILL.md 必须使用 UTF-8 编码。"; +"SKILL.md needs YAML frontmatter." = "SKILL.md 需要 YAML 格式的文件头。"; +"SKILL.md has invalid YAML frontmatter." = "SKILL.md 的 YAML 文件头格式不正确。"; +"SKILL.md needs a name and description." = "SKILL.md 需要技能名称和说明。"; +"SKILL.md frontmatter is not closed." = "SKILL.md 的文件头缺少结束标记。"; +"Enter a description and instructions." = "请填写技能说明和正文。"; +"Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "技能名称最多 64 个字符,请使用小写字母、数字和单个连字符。"; +"Select SKILL.md or its containing directory." = "请选择 SKILL.md 文件或包含该文件的目录。"; +"A skill with this directory name already exists. Existing skills are never overwritten." = "同名技能目录已存在,不会覆盖已有技能。"; +"Skill imports cannot contain symbolic links or special files." = "导入的技能不能包含符号链接或特殊文件。"; +"Import a regular file or directory instead of a symbolic link." = "请导入普通文件或目录,而不是符号链接。"; +"The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory." = "技能目标位置包含符号链接或非目录项目,请使用普通技能目录。"; +"The skill could not be published. Check permissions and existing skill names." = "无法保存技能,请检查目录权限以及是否存在同名技能。"; +"Codex reported skill discovery errors. Check the affected SKILL.md files and refresh." = "Codex 报告了技能发现错误,请检查 SKILL.md 文件并刷新。"; +"A skill was omitted because its native metadata is incomplete." = "有技能的原生信息不完整,未在列表中显示。"; +"A skill has an unknown scope and cannot be classified." = "有技能的作用范围未知,暂时无法分类。"; +"That skill name is reserved by Claude. Choose a different name." = "该技能名称为 Claude 保留,请使用其他名称。"; +"SKILL.md exceeds the 128 KiB limit." = "SKILL.md 超过了 128 KiB 的大小限制。"; +"SKILL.md needs instructions after its frontmatter." = "请在 SKILL.md 的文件头后添加技能正文。"; + +"Enable skill" = "启用技能"; + +"State unknown" = "状态未知"; + +"Switches save native Agent settings. Reconnect existing conversations to refresh available skills." = "开关保存到 Agent 原生配置。已有对话需重新连接以刷新可用技能。"; + +"Skill setting saved." = "技能设置已保存。"; + +"Codex saves this setting by path for all projects using this skill." = "Codex 按路径保存此设置,影响使用该技能路径的所有项目。"; + +"Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting." = "Claude 按名称保存可见性:全局技能写入用户配置,项目技能写入项目本地配置。项目及受管配置可能覆盖全局设置;同名技能共用此设置。"; + +"Skill settings could not be updated. Check the settings file and refresh." = "无法更新技能设置,请检查配置文件后刷新。"; + +"Claude skill settings could not be read. Fix invalid settings and refresh before toggling." = "无法读取 Claude 技能配置,请修复无效配置后刷新再操作开关。"; + +"Choose whether to enable the skill." = "请选择启用或停用技能。"; + +"Codex skill settings could not be confirmed. Refresh to check the current state." = "无法确认 Codex 技能设置,请刷新查看当前状态。"; + +"The Agent did not apply the skill setting. Refresh and check project or managed configuration." = "Agent 未应用此技能设置,请刷新并检查项目或受管配置。"; + +"The skill is no longer available in this scope. Refresh and retry." = "此技能已不在当前范围内,请刷新后重试。"; + +"Claude plugin skills must be managed through Claude's plugin settings." = "Claude 插件技能需通过 Claude 的插件设置管理。"; + +"Skill settings are busy. Retry after the other change finishes." = "技能配置正在被修改,请等待其他修改完成后重试。"; + +"Skill settings changed elsewhere. Refresh and retry." = "技能配置已在其他位置更改,请刷新后重试。"; + +/* MCP discovery and import */ +"Discover MCP servers" = "发现 MCP 服务器"; +"Search MCP servers" = "搜索 MCP 服务器"; +"Import JSON" = "导入 JSON"; +"Search the official MCP Registry" = "搜索官方 MCP Registry"; +"Official MCP Registry" = "官方 MCP Registry"; +"Featured and the official MCP Registry" = "精选,以及官方 MCP Registry"; +"Registry website" = "Registry 网站"; +"No matching MCP servers" = "没有匹配的 MCP 服务器"; +"Configure…" = "配置…"; +"Load more" = "加载更多"; +"Import MCP configuration" = "导入 MCP 配置"; +"MCP JSON" = "MCP JSON"; +"Review configuration" = "检查配置"; +"Preview import" = "预览导入"; +"New MCP server" = "新建 MCP 服务器"; +"Edit MCP server" = "编辑 MCP 服务器"; +"The MCP Registry could not be loaded. Try again or add a server manually." = "无法加载 MCP Registry。请重试,或手动添加服务器。"; +"Paste a JSON object containing mcpServers." = "请粘贴包含 mcpServers 的 JSON 对象。"; +"Could not import MCP JSON. Check the mcpServers object and try again." = "无法导入 MCP JSON。请检查 mcpServers 对象后重试。"; +"Public directory metadata is supplied by the official MCP Registry. Listing is not an endorsement or a security review. Adding opens an editable configuration; Lithe does not download or run server packages. Servers with complex inputs must be configured using the publisher's documentation." = "目录信息来自官方 MCP Registry,收录不代表推荐或安全审查。添加时先打开配置编辑器,Lithe 不会下载或运行服务器包。需要复杂参数的服务器请参考发布者文档配置。"; +"Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry." = "粘贴包含 mcpServers 的标准 JSON,选择服务器并检查后再保存。只导入编辑器支持的命令、参数、环境变量、URL 和请求头;认证及客户端专有选项需另行配置。导入不会接管已有条目。"; + +"Featured" = "精选"; +"The featured MCP catalog could not be loaded." = "无法加载内置 MCP 精选目录。"; +"A secure connection to the MCP Registry could not be established. Check the proxy or retry directly." = "无法与 MCP Registry 建立安全连接。请检查代理,或使用直连重试。"; +"The MCP Registry request timed out. Try again." = "MCP Registry 请求超时,请重试。"; +"The MCP Registry is unreachable. Check the network or proxy." = "无法连接 MCP Registry,请检查网络或代理。"; +"Search MCP servers — GitHub, browsers, documentation…" = "搜索 MCP 服务器 — GitHub、浏览器、文档…"; +"Featured servers ship with Lithe and work offline. Searching also checks the official MCP Registry. Listing is not a security review. Adding opens a configuration to review; packages are not downloaded or run. Follow the publisher's documentation for authentication and other requirements." = "精选服务器随 Lithe 提供,无需联网即可浏览。搜索时也会查询官方 MCP Registry。收录不代表安全审核。添加时先检查配置,不会下载或运行服务器包;认证等要求请参考发布者文档。"; +"Direct connection" = "直连"; +"Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled." = "直连仅用于此页面的公开 MCP Registry 查询,不修改系统代理或 Agent 的服务器连接设置,并继续校验 TLS 证书。"; +"Use system proxy" = "使用系统代理"; +"Showing available matches." = "仍显示可用的匹配项。"; +"Retry directly" = "直连重试"; +"Current library documentation and code examples for coding agents." = "为编程 Agent 提供最新的库文档和代码示例。"; +"Open pages, click, type and inspect a browser through its accessibility tree." = "打开网页、点击、输入,并通过无障碍树读取浏览器页面。"; +"Inspect Chrome pages, console messages, network requests and performance." = "检查 Chrome 页面、控制台消息、网络请求和性能。"; +"Search official Microsoft and Azure documentation and code samples." = "搜索微软与 Azure 的官方文档和代码示例。"; + +/* Shared instruction library */ +"Shared instructions" = "共享指令"; +"Default instructions" = "默认指令"; +"Empty" = "空"; +"Save shared instructions" = "保存共享指令"; +"Shared instructions saved." = "共享指令已保存。"; +"Use shared instructions" = "使用共享指令"; +"Sync needed" = "待同步"; +"Sync again" = "重新同步"; +"%d lines of its own" = "%d 行自有内容"; +"Native instruction file" = "原生指令文件"; +"Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply." = "共享指令只需写一次,再为各 Agent 开启。Lithe 只修改标记区块,保留原生文件的其他内容。新建对话后加载更新,项目指令仍然生效。"; +"The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; +"This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents." = "此处显示包含 Lithe 共享区块在内的完整原生文件。在此修改共享部分可能导致不同步;请在共享编辑器中更新所有已开启的 Agent。"; +"The shared instruction directory must be absolute." = "共享指令目录必须为绝对路径。"; +"Shared instructions changed outside this editor. Reload before saving." = "共享指令已被其他程序修改,请重新加载后再保存。"; +"Invalid shared instruction request." = "共享指令请求无效。"; +"Instruction synchronization was cancelled." = "指令同步已取消。"; +"Reload this Agent before synchronizing instructions." = "请重新加载此 Agent 的指令后再同步。"; +"The Lithe shared instruction markers were changed. Repair the native file before syncing." = "Lithe 共享指令标记已被修改,请先修复原生文件再同步。"; +"Shared instructions cannot contain Lithe synchronization markers." = "共享指令正文不能包含 Lithe 同步标记。"; +"Instruction Agents" = "Agent"; + +/* Featured MCP publishers and setup */ +"Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required." = "访问 GitHub 仓库、Issue、PR 和 Actions。需要个人访问令牌。"; +"Ask questions about public GitHub repositories and read their generated documentation." = "向公开 GitHub 仓库提问,阅读自动生成的项目文档。"; +"Search the web and code, and retrieve page content for coding agents." = "为编程 Agent 搜索网页和代码,获取页面内容。"; +"Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "读写、搜索你允许的目录中的文件。需要 Node.js 24 或更高版本。"; +"Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later." = "抓取网页并转换为 Markdown。需要 uv 和 Python 3.10 或更高版本。"; +"Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later." = "跨对话保留知识图谱。需要 Node.js 24 或更高版本。"; +"GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file." = "GitHub 需要个人访问令牌。请将 Authorization 请求头填写为 Bearer 加空格和令牌。令牌会保存在 Agent 配置文件中。"; +"Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "在包参数后至少添加一个文件夹的绝对路径,每行一个。Filesystem 只可读写你允许的文件夹。需要 Node.js 24 或更高版本。"; +"Enter an Authorization header with Bearer and your GitHub token." = "请填写 Authorization 请求头,内容为 Bearer 加空格和 GitHub 令牌。"; +"Add at least one absolute allowed folder path after the Filesystem package argument." = "请在 Filesystem 包参数后至少添加一个允许访问的文件夹绝对路径。"; + +/* MCP native tools inspection and compact editor */ +"Server tools" = "服务器工具"; +"%d tools" = "%d 个工具"; +"Not detected" = "未检测"; +"Detect again" = "重新检测"; +"Detect tools" = "检测工具"; +"Reload tool settings" = "重新加载工具设置"; +"This server reported no tools." = "服务器未提供工具。"; +"Run this MCP command?" = "运行此 MCP 命令?"; +"Run and detect" = "运行并检测"; +"Remove from library" = "从资源库移除"; +"Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process." = "检测会运行配置的程序,并可能下载软件包。请仅对信任的服务器继续。Lithe 会读取工具列表,然后停止检测进程。"; +"Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes." = "检测会连接服务器并读取工具信息,不会调用工具。本地命令可能执行代码或下载软件包。结果仅代表此次检测,不代表已有 Agent 会话的状态。工具开关作用于所选 Agent 的用户配置,项目或受管规则仍可能限制访问。修改后请新建 Agent 会话。"; +"This tool is restricted outside Lithe. Edit the native Agent policy to enable it." = "该工具已被 Lithe 之外的规则限制,请编辑 Agent 原生规则后再启用。"; +"Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated." = "按终端形式输入程序和参数,含空格的路径请加引号。Lithe 直接传递参数,不运行 Shell,也不解释管道或重定向。"; +"Could not read the command line." = "无法读取命令行。"; +"Check command-line quotes before saving." = "请检查命令行中的引号后再保存。"; +"Check the MCP command, URL and headers." = "请检查 MCP 命令、URL 和请求头。"; +"Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint." = "暂不支持检测旧式 SSE 连接,请使用发布者提供的 Streamable HTTP 地址。"; +"Could not start the MCP command. Check its executable and runtime." = "无法启动 MCP 命令,请检查程序及所需运行时。"; +"MCP connection failed. Check the server, network and authentication." = "MCP 连接失败,请检查服务器、网络及认证。"; +"MCP tool metadata exceeded the inspection limit." = "MCP 工具信息超过检测上限。"; +"MCP detection timed out after 30 seconds." = "MCP 检测已超时(30 秒)。"; +"MCP detection was cancelled." = "MCP 检测已取消。"; +"Invalid MCP tool configuration" = "MCP 工具配置无效"; +"Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name." = "Claude Code 存在 MCP 工具限制。重命名服务器前请先更新其原生规则,以免已禁用的工具在新名称下重新可用。"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index b61a30abda..d96fd7e853 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -14,6 +14,8 @@ final class AppServices { /// Codex and Claude Code MCP configuration files. `nil` before platform /// composition, which leaves the settings page read-only. var mcpConfigurationStore: (any McpConfigurationStore)? + var mcpRegistry: (any McpRegistryProviding)? + var mcpTools: (any McpToolsManaging)? /// Confirms closing terminals with running programs; `nil` closes directly. var processExitCoordinator: ProcessExitCoordinator? /// Gives build providers that share a language server the language @@ -65,6 +67,7 @@ final class AppServices { let credentialResolver: any AIProviderCredentialResolver /// Detects Node.js and installs ACP adapters for the Agents settings page. let agentManagement: any AgentManagementService + let agentInstructions: any AgentInstructionsService let agentProviderConfigurationParser: any AgentProviderConfigurationParsing let aiConfigurationSources: [any AIConfigurationSource] let recentProjectsStore: RecentProjectsStore @@ -110,6 +113,7 @@ final class AppServices { diagnosticsExportService: DiagnosticsExportService, credentialResolver: any AIProviderCredentialResolver, agentManagement: any AgentManagementService = UnavailableAgentManagementService(), + agentInstructions: any AgentInstructionsService = UnavailableAgentInstructionsService(), agentProviderConfigurationParser: any AgentProviderConfigurationParsing = UnavailableAgentProviderConfigurationParser(), aiConfigurationSources: [any AIConfigurationSource], recentProjectsStore: RecentProjectsStore, @@ -167,6 +171,7 @@ final class AppServices { self.diagnosticsExportService = diagnosticsExportService self.credentialResolver = credentialResolver self.agentManagement = agentManagement + self.agentInstructions = agentInstructions self.agentProviderConfigurationParser = agentProviderConfigurationParser self.aiConfigurationSources = aiConfigurationSources self.recentProjectsStore = recentProjectsStore diff --git a/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift new file mode 100644 index 0000000000..5d17809516 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift @@ -0,0 +1,181 @@ +import Foundation +import LitheCoreContracts + +/// Settings edits the upstream files directly; drafts survive navigation and failures. +@MainActor +final class AgentInstructionsFeatureModel: ObservableObject { + enum Phase: Equatable { case idle, loading, ready, saving, failed(String) } + + @Published var selectedAgent: AgentInstructionKind = .codex + @Published private(set) var isEditorExpanded = false + @Published private(set) var files: [AgentInstructionKind: AgentInstructionFile] = [:] + @Published private(set) var drafts: [AgentInstructionKind: String] = [:] + @Published private(set) var phases: [AgentInstructionKind: Phase] = [:] + @Published private(set) var saved: Set = [] + @Published private(set) var sharedLibrary: SharedInstructionLibrary? + @Published private(set) var sharedDraft = "" + @Published private(set) var sharedPhase: Phase = .idle + @Published private(set) var sharedSaved = false + @Published private(set) var pendingSharedAgents: Set = [] + @Published private(set) var sharedSyncErrors: [AgentInstructionKind: String] = [:] + private var sharedSwitches: [(AgentInstructionKind, Bool)] = [] + private var drainsSharedSwitches = false + private let service: any AgentInstructionsService + + init(service: any AgentInstructionsService) { self.service = service } + + var file: AgentInstructionFile? { files[selectedAgent] } + var draft: String { drafts[selectedAgent] ?? "" } + var phase: Phase { phases[selectedAgent] ?? .idle } + var isBusy: Bool { phase == .loading || phase == .saving } + var isDirty: Bool { file.map { draft != $0.content } ?? false } + var canSave: Bool { isDirty && !isBusy && !isSharedBusy && draft.utf8.count <= 1024 * 1024 } + + var isSharedBusy: Bool { sharedPhase == .loading || sharedPhase == .saving } + var isSharedDirty: Bool { sharedLibrary.map { sharedDraft != $0.file.content } ?? false } + var canSaveShared: Bool { sharedLibrary != nil && isSharedDirty && !isSharedBusy + && !phases.values.contains(.saving) && sharedDraft.utf8.count <= 1024 * 1024 } + + func sharedState(_ agent: AgentInstructionKind) -> SharedInstructionAgent? { + sharedLibrary?.agents.first { $0.agentId == agent.rawValue } + } + + func canToggleShared(_ agent: AgentInstructionKind) -> Bool { + (!isSharedBusy || drainsSharedSwitches) && !pendingSharedAgents.contains(agent) + && !isSharedDirty && phases[agent] != .saving + && drafts[agent] == files[agent]?.content && sharedState(agent)?.enabled != nil + } + + func editShared(_ text: String) { + guard !isSharedBusy else { return } + sharedDraft = text + sharedSaved = false + } + + func loadShared(reload: Bool = false) async { + guard !isSharedBusy, !phases.values.contains(.saving) else { return } + let retainedFile = !reload && isSharedDirty ? sharedLibrary?.file : nil + sharedPhase = .loading + do { + let library = try await service.readShared() + // Navigation refreshes real switch state, but only explicit reload + // replaces an unsaved shared draft and its original conflict guard. + applyShared(retainedFile.map { SharedInstructionLibrary(file: $0, agents: library.agents) } ?? library) + if retainedFile == nil { sharedDraft = library.file.content } + sharedSaved = false + sharedSyncErrors.removeAll() + sharedPhase = .ready + } catch is CancellationError { sharedPhase = .idle } + catch { sharedPhase = .failed(error.localizedDescription) } + } + + func saveShared() async { + guard canSaveShared, let library = sharedLibrary else { return } + sharedPhase = .saving + do { + applyShared(try await service.saveShared(content: sharedDraft, library: library)) + sharedSaved = true + sharedPhase = .ready + } catch { sharedPhase = .failed(error.localizedDescription) } + } + + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind) async { + guard canToggleShared(agent), sharedLibrary != nil else { return } + pendingSharedAgents.insert(agent) + sharedSwitches.append((agent, enabled)) + guard !drainsSharedSwitches else { return } + drainsSharedSwitches = true + defer { + drainsSharedSwitches = false + pendingSharedAgents.removeAll() + sharedSwitches.removeAll() + } + // The first view-owned task drains the queue. Later rows stay interactive + // and use the preceding operation's fresh native/source revisions. + while !sharedSwitches.isEmpty, !Task.isCancelled { + let (target, value) = sharedSwitches.removeFirst() + guard let library = sharedLibrary else { break } + sharedPhase = .saving + do { + applyShared(try await service.setSharedEnabled(value, agent: target, library: library)) + sharedSyncErrors.removeValue(forKey: target) + } catch { sharedSyncErrors[target] = error.localizedDescription } + pendingSharedAgents.remove(target) + sharedPhase = .ready + } + } + + private func applyShared(_ library: SharedInstructionLibrary) { + sharedLibrary = library + for agent in AgentInstructionKind.allCases { + guard let updated = sharedState(agent)?.file else { continue } + // A shared update never overwrites an independent native-file draft. + guard files[agent] == nil || drafts[agent] == files[agent]?.content else { continue } + files[agent] = updated + drafts[agent] = updated.content + phases[agent] = .ready + } + } + + func edit(_ text: String) { + guard !isBusy, !isSharedBusy else { return } + drafts[selectedAgent] = text + saved.remove(selectedAgent) + } + + func toggleEditor(_ agent: AgentInstructionKind) { + isEditorExpanded = selectedAgent == agent ? !isEditorExpanded : true + selectedAgent = agent + } + + /// Listing both files is read-only. One unavailable Agent must not hide the other. + func loadAll() async { + for agent in AgentInstructionKind.allCases { + guard !Task.isCancelled else { return } + await load(agent: agent) + } + } + + /// Initial loads retain cached drafts. Explicit reload replaces a draft only after success. + func load(reload: Bool = false) async { + await load(agent: selectedAgent, reload: reload) + } + + private func load(agent: AgentInstructionKind, reload: Bool = false) async { + let phase = phases[agent] ?? .idle + guard phase != .loading, phase != .saving, reload || files[agent] == nil else { return } + phases[agent] = .loading + do { + let file = try await service.read(agent: agent) + files[agent] = file + drafts[agent] = file.content + saved.remove(agent) + phases[agent] = .ready + } catch is CancellationError { + phases[agent] = files[agent] == nil ? .idle : .ready + } catch { + phases[agent] = .failed(error.localizedDescription) + } + } + + func save() async { + let agent = selectedAgent + guard canSave, let file = files[agent] else { return } + let content = drafts[agent] ?? "" + phases[agent] = .saving + do { + let updated = try await service.save(file: file, content: content) + files[agent] = updated + // Text controls are disabled during save; selection may still change. + drafts[agent] = updated.content + saved.insert(agent) + phases[agent] = .ready + if sharedLibrary != nil { + do { applyShared(try await service.readShared()) } + catch { sharedPhase = .failed(error.localizedDescription) } + } + } catch { + phases[agent] = .failed(error.localizedDescription) + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift new file mode 100644 index 0000000000..b3527054b4 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift @@ -0,0 +1,189 @@ +import Foundation +import LitheCoreContracts + +/// Settings-only state. Discovery and file management never start a conversation. +@MainActor +final class AgentSkillsFeatureModel: ObservableObject { + struct Context: Equatable { let workspace: URL? } + struct Item: Identifiable, Equatable { + let agentID: String + let source: String + let skill: AgentSkill + var id: String { agentID + ":" + skill.id } + var agentName: String { agentID == "codex-acp" ? "Codex" : "Claude" } + } + enum Phase: Equatable { case idle, loading, ready, failed } + struct PendingEnablement { + let item: Item + let enabled: Bool + var itemID: String { item.id } + } + static let agents = ["codex-acp", "claude-acp"] + @Published private(set) var phase: Phase = .idle + @Published private(set) var listings: [String: AgentSkillListing] = [:] + @Published private(set) var directories: [String: [String: String]] = [:] + @Published private(set) var discoveryErrors: [String: String] = [:] + @Published private(set) var error: String? + @Published private(set) var savedURL: URL? + @Published private(set) var isWriting = false + @Published private(set) var pendingEnablements: [PendingEnablement] = [] + @Published private(set) var didChangeEnablement = false + @Published var query = "" + @Published var scope = "user" + @Published var agentFilter = "all" + private let service: any AgentManagementService + private var context: Context? + private var revision = UUID() + private var writeTask: Task? + private var refreshTask: Task? + + init(service: any AgentManagementService) { self.service = service } + + private var matchingItems: [Item] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + return Self.agents.filter { agentFilter == "all" || agentFilter == $0 }.flatMap { agentID -> [Item] in + guard let listing = listings[agentID] else { return [Item]() } + return listing.skills.map { Item(agentID: agentID, source: listing.source, skill: $0) } + }.filter { item in + query.isEmpty || [item.skill.name, item.skill.description, item.skill.path] + .contains { $0.localizedCaseInsensitiveContains(query) } + }.sorted { + ($0.skill.name.lowercased(), $0.agentID, $0.skill.path) < ($1.skill.name.lowercased(), $1.agentID, $1.skill.path) + } + } + + func count(in scope: String) -> Int { matchingItems.filter { belongs($0, to: scope) }.count } + var visibleItems: [Item] { matchingItems.filter { belongs($0, to: scope) } } + /// Display the user's choice immediately without replacing confirmed native state. + func displayedEnablement(for item: Item) -> Bool? { + if let pending = pendingEnablements.first(where: { $0.itemID == item.id }) { return pending.enabled } + return item.skill.enabled + } + func canChangeEnablement(for item: Item) -> Bool { + phase != .loading && (!isWriting || !pendingEnablements.isEmpty) + && !pendingEnablements.contains(where: { $0.itemID == item.id }) + } + private func belongs(_ item: Item, to scope: String) -> Bool { + scope == "repo" ? (context?.workspace != nil && item.skill.scope == "repo") : ["user", "system", "admin"].contains(item.skill.scope) + } + + func directory(agentID: String, scope: String) -> String? { directories[agentID]?[scope] } + + func read(_ item: Item) async throws -> String { + try await service.readSkill(at: URL(fileURLWithPath: item.skill.path), agentID: item.agentID) + } + + func reload(_ next: Context) { + guard !isWriting else { return } + refreshTask?.cancel() + refreshTask = Task { [weak self] in await self?.refresh(next) } + } + + func refresh(_ next: Context) async { + // A manual refresh must not invalidate a write in the same context. + guard !isWriting || context != next else { return } + if context != next { + writeTask?.cancel(); writeTask = nil; isWriting = false + pendingEnablements = []; didChangeEnablement = false + listings = [:]; directories = [:]; discoveryErrors = [:]; savedURL = nil + } + context = next + let token = UUID(); revision = token + error = nil; phase = .loading + let service = service + // A missing Codex CLI must not suppress the independently available Claude inventory. + async let codex = Self.load(service, agentID: "codex-acp", workspace: next.workspace) + async let claude = Self.load(service, agentID: "claude-acp", workspace: next.workspace) + async let codexDirectories = Self.loadDirectories(service, agentID: "codex-acp", workspace: next.workspace) + async let claudeDirectories = Self.loadDirectories(service, agentID: "claude-acp", workspace: next.workspace) + let results = await [codex, claude] + let paths = await [codexDirectories, claudeDirectories] + guard !Task.isCancelled, revision == token else { return } + for (index, agentID) in Self.agents.enumerated() { + let result = results[index] + switch result { + case .success(let listing): listings[agentID] = listing; discoveryErrors[agentID] = nil + case .failure(let reason): discoveryErrors[agentID] = reason.message + } + switch paths[index] { + case .success(let paths): directories[agentID] = paths + case .failure(let reason): + directories[agentID] = listings[agentID]?.directories ?? [:] + discoveryErrors[agentID] = [discoveryErrors[agentID], reason.message].compactMap { $0 }.joined(separator: "\n") + } + } + phase = discoveryErrors.count == Self.agents.count ? .failed : .ready + } + + private nonisolated static func loadDirectories(_ service: any AgentManagementService, agentID: String, workspace: URL?) async -> Result<[String: String], Failure> { + // Destination lookup creates no files and needs no CLI. + do { return .success(try await service.skillDirectories(agentID: agentID, workspace: workspace)) } + catch { return .failure(Failure(message: error.localizedDescription)) } + } + + private struct Failure: Error { let message: String } + private nonisolated static func load(_ service: any AgentManagementService, agentID: String, workspace: URL?) async -> Result { + do { return .success(try await service.listSkills(agentID: agentID, workspace: workspace)) } + catch { return .failure(Failure(message: error.localizedDescription)) } + } + + /// Capture the project before awaiting; switching projects invalidates delayed results. + func setEnabled(_ enabled: Bool, for item: Item, context expected: Context) { + guard canChangeEnablement(for: item), context == expected, + item.skill.enabled != nil, item.skill.enabled != enabled, + listings[item.agentID]?.skills.contains(item.skill) == true else { return } + pendingEnablements.append(.init(item: item, enabled: enabled)) + guard !isWriting else { return } + isWriting = true; error = nil; didChangeEnablement = false + let token = UUID(); revision = token + let service = service + writeTask = Task { [weak self] in + // Serialize native read/write/read exchanges while only locking the rows awaiting confirmation. + while let pending = self?.pendingEnablements.first { + guard !Task.isCancelled, self?.revision == token else { return } + let item = pending.item + let request = AgentSkillEnablement(agentID: item.agentID, workspace: expected.workspace, + skill: item.skill, enabled: pending.enabled) + do { + let listing = try await service.setSkillEnabled(request) + guard let self, !Task.isCancelled, self.revision == token else { return } + self.listings[item.agentID] = listing + self.discoveryErrors[item.agentID] = nil + } catch { + guard let self, !Task.isCancelled, self.revision == token else { return } + self.error = error.localizedDescription + } + self?.pendingEnablements.removeFirst() + } + guard let self, !Task.isCancelled, self.revision == token else { return } + self.isWriting = false; self.didChangeEnablement = self.error == nil; self.writeTask = nil + } + } + + func write(_ request: AgentSkillWrite, context expected: Context) { + guard !isWriting, context == expected, phase != .loading else { return } + isWriting = true; error = nil; savedURL = nil + let token = UUID(); revision = token + let service = service + writeTask = Task { [weak self] in + do { + try Task.checkCancellation() + let url = try await service.writeSkill(request) + guard let self, !Task.isCancelled, self.revision == token else { return } + self.savedURL = url; self.isWriting = false + await self.refresh(expected) + if self.context == expected { self.writeTask = nil } + } catch { + guard let self, !Task.isCancelled, self.revision == token else { return } + self.error = error.localizedDescription; self.isWriting = false; self.writeTask = nil + } + } + } + + func dismissResult() { savedURL = nil; error = nil; didChangeEnablement = false } + func cancel() { + revision = UUID(); writeTask?.cancel(); writeTask = nil; refreshTask?.cancel(); refreshTask = nil; isWriting = false + pendingEnablements = []; didChangeEnablement = false + if phase == .loading { phase = .idle } + } +} diff --git a/macos/Sources/Lithe/Application/Features/McpRegistryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpRegistryFeatureModel.swift new file mode 100644 index 0000000000..ee8eb5f992 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/McpRegistryFeatureModel.swift @@ -0,0 +1,89 @@ +import Combine +import Foundation + +/// Owns one paginated search. Cancellation and request identity prevent a late +/// response from replacing the results of a newer query. +@MainActor +final class McpRegistryFeatureModel: ObservableObject { + @Published private(set) var entries: [McpRegistryEntry] = [] + @Published private(set) var isLoading = false + @Published private(set) var error: String? + @Published private(set) var nextCursor: String? + @Published private(set) var canRetryDirectly = false + private let registry: (any McpRegistryProviding)? + private let featured: [McpRegistryEntry] + private let featuredError: String? + private var requestID = UUID() + private var query = "" + private var route = McpRegistryRoute.system + + init(registry: (any McpRegistryProviding)?) { + self.registry = registry + do { + featured = try registry?.featured().entries ?? [] + featuredError = nil + } catch { + featured = [] + featuredError = String(localized: "The featured MCP catalog could not be loaded.") + } + entries = featured + } + + func search(_ text: String, loadMore: Bool = false, route: McpRegistryRoute = .system) async { + let id = UUID() + requestID = id + let search = text.trimmingCharacters(in: .whitespacesAndNewlines) + let cursor = loadMore && search == query && route == self.route ? nextCursor : nil + if cursor == nil { + entries = featured.filter { + search.isEmpty || [$0.id, $0.title, $0.publisher ?? "", $0.summary, + String(localized: String.LocalizationValue($0.summary))] + .contains { $0.localizedCaseInsensitiveContains(search) } + } + nextCursor = nil + } + query = search + self.route = route + error = featuredError + canRetryDirectly = false + // The landing page is release-owned content, available before any request. + // Only explicit searches ask the public Registry to extend these results. + guard !search.isEmpty else { isLoading = false; return } + isLoading = true + defer { if requestID == id { isLoading = false } } + do { + guard let registry else { throw McpLibraryError.unavailable } + let page = try await registry.search(query: search, cursor: cursor, route: route) + guard requestID == id, !Task.isCancelled else { return } + var seen = Set(entries.map(\.id)) + var connections = Set(entries.compactMap { connection(of: $0) }) + entries += page.entries.filter { entry in + guard seen.insert(entry.id).inserted else { return false } + return connection(of: entry).map { connections.insert($0).inserted } ?? true + } + nextCursor = page.nextCursor == cursor ? nil : page.nextCursor + } catch { + guard requestID == id, !Task.isCancelled else { return } + canRetryDirectly = route == .system + switch error as? McpRegistryError { + case .secureConnection: + self.error = String(localized: "A secure connection to the MCP Registry could not be established. Check the proxy or retry directly.") + case .timeout: + self.error = String(localized: "The MCP Registry request timed out. Try again.") + case .offline: + self.error = String(localized: "The MCP Registry is unreachable. Check the network or proxy.") + default: + canRetryDirectly = false + self.error = String(localized: "The MCP Registry could not be loaded. Try again or add a server manually.") + } + } + } + + private func connection(of entry: McpRegistryEntry) -> McpServerDefinition? { + guard var definition = entry.draft else { return nil } + definition.name = "" + definition.definitionHash = "" + definition.sourceHash = nil + return definition + } +} diff --git a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift index ab3a20e6eb..142a472c7c 100644 --- a/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift @@ -104,6 +104,18 @@ struct McpServerDraft: Equatable { var env: [McpKeyValue] = [] var headers: [McpKeyValue] = [] var enabledAgents: Set = [] + /// Present only while reviewing an incomplete featured template. + var setup: McpRegistrySetup? + + var setupMessage: String? { + switch setup { + case .bearerToken: + String(localized: "GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file.") + case .allowedDirectories: + String(localized: "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later.") + case nil: nil + } + } var isNew: Bool { originalRowID == nil } @@ -134,6 +146,32 @@ struct McpServerDraft: Equatable { headers = row.definition.headers enabledAgents = row.enabledGlobalAgents } + + init(definition: McpServerDefinition) { + name = definition.name + transport = definition.transport + command = definition.command ?? "" + url = definition.url ?? "" + originalArguments = definition.args + argumentsText = definition.args.joined(separator: "\n") + env = definition.env + headers = definition.headers + } + + mutating func setCommand(_ value: String, arguments: [String]) { + command = value + originalArguments = arguments + argumentsText = arguments.joined(separator: "\n") + } + + init?(entry: McpRegistryEntry) { + guard let definition = entry.draft else { return nil } + self.init(definition: definition) + if entry.source == .featured { + setup = entry.setup + if setup == .bearerToken { headers = [McpKeyValue(key: "Authorization", value: "")] } + } + } } /// Project-level Lithe registration outcome, shown next to the access section. @@ -177,11 +215,18 @@ enum McpLibraryError: LocalizedError { /// Application dependencies used by the MCP library. @MainActor protocol McpServerLibraryActions: AnyObject { + var mcpTools: (any McpToolsManaging)? { get } + var mcpRegistry: (any McpRegistryProviding)? { get } var mcpConfigurationStore: (any McpConfigurationStore)? { get } var mcpWorkspaceURL: URL? { get } var mcpLedgerStore: any KeyValueStore { get } } +extension McpServerLibraryActions { + var mcpTools: (any McpToolsManaging)? { nil } + var mcpRegistry: (any McpRegistryProviding)? { nil } +} + /// Discovers, edits and removes MCP servers registered with Codex and Claude Code. /// /// Core plans every write against the ownership ledger; this model stages all @@ -190,6 +235,8 @@ protocol McpServerLibraryActions: AnyObject { /// included in errors. @MainActor final class McpServerLibraryFeatureModel: ObservableObject { + let tools: McpToolsFeatureModel + let discovery: McpRegistryFeatureModel @Published private(set) var rows: [McpServerRow] = [] @Published private(set) var isAvailable = false @Published private(set) var loadErrors: [String] = [] @@ -211,6 +258,11 @@ final class McpServerLibraryFeatureModel: ObservableObject { init(model: any McpServerLibraryActions) { self.model = model + tools = McpToolsFeatureModel(service: model.mcpTools) + discovery = McpRegistryFeatureModel(registry: model.mcpRegistry) + tools.didChangePolicy = { [weak self] row, snapshot in + self?.recordToolPolicy(row: row, snapshot: snapshot) + } } // MARK: - Discovery @@ -282,6 +334,15 @@ final class McpServerLibraryFeatureModel: ObservableObject { return left.definition.definitionHash < right.definition.definitionHash } hasLoaded = true + tools.reconcile(rows) + } + + func catalogEntry(for definition: McpServerDefinition) -> McpRegistryEntry? { + discovery.entries.first { entry in + guard entry.source == .featured, let draft = entry.draft else { return false } + if definition.transport != .stdio { return draft.transport == definition.transport && draft.url == definition.url } + return draft.transport == .stdio && draft.command == definition.command && draft.args == Array(definition.args.prefix(draft.args.count)) + } } /// Files a save writes to, shown in the editor before committing. @@ -296,6 +357,17 @@ final class McpServerLibraryFeatureModel: ObservableObject { func makeDraft() -> McpServerDraft { McpServerDraft() } + /// Import is a preview only. Existing Core parsing validates the JSON; + /// no configuration or ownership record changes until the editor saves. + func importedDrafts(_ json: String) throws -> [McpServerDraft] { + guard let store = model?.mcpConfigurationStore, + let file = store.configurationFiles(workspace: nil).first(where: { $0.agent == .claudeCode && $0.scope == .global }) + else { throw McpLibraryError.unavailable } + let servers = try store.parse(file, content: json) + guard !servers.isEmpty else { throw McpLibraryError.invalidDraft(String(localized: "Paste a JSON object containing mcpServers.")) } + return servers.map(McpServerDraft.init(definition:)) + } + func draft(for row: McpServerRow) -> McpServerDraft { McpServerDraft(row: row) } /// Returns a user-facing reason when the draft cannot be saved. @@ -331,6 +403,20 @@ final class McpServerLibraryFeatureModel: ObservableObject { return String(localized: "Enter a complete server URL.") } } + switch draft.setup { + case .bearerToken: + let value = draft.headers.first { $0.key.lowercased() == "authorization" }?.value ?? "" + let parts = value.split(whereSeparator: \.isWhitespace) + if draft.transport == .stdio || parts.count != 2 || parts[0].lowercased() != "bearer" { + return String(localized: "Enter an Authorization header with Bearer and your GitHub token.") + } + case .allowedDirectories: + let folders = Array(draft.arguments.dropFirst(2)) + if draft.transport != .stdio || folders.isEmpty || folders.contains(where: { !$0.hasPrefix("/") }) { + return String(localized: "Add at least one absolute allowed folder path after the Filesystem package argument.") + } + case nil: break + } return nil } @@ -353,6 +439,12 @@ final class McpServerLibraryFeatureModel: ObservableObject { let targetFiles = files.filter { draft.enabledAgents.contains($0.agent) } let originalName = draft.originalName let originalSources = draft.originalSources + if let originalName, originalName != draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + let original = rows.first(where: { $0.id == draft.originalRowID }) { + do { + try model?.mcpTools?.validateRename(McpToolTarget(definition: original.definition, files: originalSources.map(\.file))) + } catch { self.error = error.localizedDescription; return false } + } var definition = McpServerDefinition( name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), @@ -512,9 +604,10 @@ final class McpServerLibraryFeatureModel: ObservableObject { !source.isOwnedByLithe else { return } error = nil var entries = ledgerEntries(for: source.file) + let filters = entries.first { $0.name == row.name }?.toolFilters entries.removeAll { $0.name == row.name } entries.append(McpOwnedEntry( - name: row.name, definitionHash: source.definitionHash, sourceHash: source.sourceHash + name: row.name, definitionHash: source.definitionHash, sourceHash: source.sourceHash, toolFilters: filters )) setLedger(entries, for: source.file) refresh() @@ -724,7 +817,8 @@ final class McpServerLibraryFeatureModel: ObservableObject { name: name, previousName: previousName, owned: owned, - expectedHash: expectedHash + expectedHash: expectedHash, + toolFilters: server.flatMap { server in owned.first(where: { $0.name == server.name })?.toolFilters?[file.url.path] } ) } @@ -774,9 +868,10 @@ final class McpServerLibraryFeatureModel: ObservableObject { completedRenames[previousName] = expectedHash } if let hash = plan.definitionHash { + let filters = entries.first { $0.name == server.name }?.toolFilters entries.removeAll { $0.name == server.name } entries.append(McpOwnedEntry( - name: server.name, definitionHash: hash, sourceHash: plan.sourceHash + name: server.name, definitionHash: hash, sourceHash: plan.sourceHash, toolFilters: filters )) } case .remove(let name, let expectedHash): @@ -804,7 +899,17 @@ final class McpServerLibraryFeatureModel: ObservableObject { } content = plan.content changed = changed || plan.changed - entries.removeAll { $0.name == name } + // Rename already carries the native fields to the new name. + // Ordinary removal retains only non-secret restrictions, + // without a source hash that could authorize a future edit. + if plan.action == "removed" { + var filters = entries.first { $0.name == name }?.toolFilters ?? [:] + filters[file.url.path] = plan.toolFilters + entries.removeAll { $0.name == name } + if !filters.isEmpty, let hash = plan.definitionHash { + entries.append(McpOwnedEntry(name: name, definitionHash: hash, toolFilters: filters)) + } + } } } planned.append((file, source, content, entries, changed)) @@ -817,6 +922,11 @@ final class McpServerLibraryFeatureModel: ObservableObject { lastBackupURL = receipts.last?.backupURL ?? lastBackupURL // A later failure must not publish ownership for an earlier write. for entry in planned where entry.changed { + for operation in operations[entry.file] ?? [] { + if case .set(let server, _, let previousName?) = operation { + model?.mcpTools?.serverRenamed(from: previousName, to: server.name, in: entry.file) + } + } setLedger(entry.entries, for: entry.file) } } @@ -858,6 +968,21 @@ final class McpServerLibraryFeatureModel: ObservableObject { ledgerSnapshot()[Self.identity(of: file)] ?? [] } + private func recordToolPolicy(row: McpServerRow, snapshot: McpToolPolicySnapshot) { + // Tool filters change Codex's full source hash, but not the connection + // identity. Advance only ownership that matched before our guarded edit. + if snapshot.agent == .codex, let previous = snapshot.previousSourceHash, + let next = snapshot.sourceHash, let source = row.globalSources.first(where: { $0.file.agent == .codex }) { + var entries = ledgerEntries(for: source.file) + if let index = entries.firstIndex(where: { $0.name == row.name && $0.sourceHash == previous }) { + entries[index] = McpOwnedEntry(name: row.name, definitionHash: row.definition.definitionHash, + sourceHash: next, toolFilters: entries[index].toolFilters) + setLedger(entries, for: source.file) + } + } + refresh() + } + /// Reads the shared ledger every time instead of caching it per window. /// Multiple windows share one store; a stale copy would rewrite the whole /// map and silently drop entries another window just recorded. diff --git a/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift new file mode 100644 index 0000000000..be291553db --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift @@ -0,0 +1,120 @@ +import Combine +import Foundation + +/// One page owns all probes. Navigation cancels them and stale responses never +/// replace another connection's inventory or publish a success after failure. +@MainActor +final class McpToolsFeatureModel: ObservableObject { + struct State { + var isLoading = false + var isReady = false + var tools: [McpToolDescription] = [] + var policies: [McpAgent: McpToolPolicySnapshot] = [:] + var error: String? + } + @Published private(set) var states: [String: State] = [:] + var didChangePolicy: ((McpServerRow, McpToolPolicySnapshot) -> Void)? + private let service: (any McpToolsManaging)? + private var probes: [String: Task] = [:] + private var requests: [String: UUID] = [:] + private var targets: [String: McpServerRow] = [:] + + init(service: (any McpToolsManaging)?) { self.service = service } + + func state(for row: McpServerRow) -> State { states[row.id] ?? State() } + + @discardableResult + func detect(_ row: McpServerRow) -> Task? { + guard let service, !row.isProjectOnly else { return nil } + cancelAll() + targets[row.id] = row + let request = UUID(); requests[row.id] = request + var state = states[row.id] ?? State(); state.isLoading = true; state.isReady = false; state.error = nil + states[row.id] = state + probes[row.id] = Task { [weak self] in + do { + let result = try await service.probe(row.definition) + guard let self, !Task.isCancelled, self.requests[row.id] == request else { return } + self.states[row.id]?.tools = result.tools + self.states[row.id]?.isReady = true + self.loadPolicies(self.targets[row.id] ?? row) + } catch { + guard let self, !Task.isCancelled, self.requests[row.id] == request else { return } + self.states[row.id]?.isReady = false + // Probe errors cross a sanitized Core boundary; no raw server + // response, URL, argument or header is put in the message. + self.states[row.id]?.error = error.localizedDescription + } + guard let self, self.requests[row.id] == request else { return } + self.states[row.id]?.isLoading = false + self.probes[row.id] = nil + self.requests[row.id] = nil + } + return probes[row.id] + } + + func loadPolicies(_ row: McpServerRow) { + guard let service, let state = states[row.id], state.isReady else { return } + service.release(Array(state.policies.values)) + states[row.id]?.policies = [:] + states[row.id]?.error = nil + for agent in McpAgent.allCases where row.enabledGlobalAgents.contains(agent) { + do { + states[row.id]?.policies[agent] = try service.policy(for: target(row), agent: agent, tools: state.tools.map(\.name)) + } catch { states[row.id]?.error = error.localizedDescription } + } + } + + func setEnabled(_ enabled: Bool, tool: String, agent: McpAgent, row: McpServerRow) { + guard let service, let snapshot = states[row.id]?.policies[agent] else { return } + do { + let next = try service.setEnabled(enabled, tool: tool, target: target(row), snapshot: snapshot) + states[row.id]?.policies[agent] = next + states[row.id]?.error = nil + didChangePolicy?(row, next) + } catch { states[row.id]?.error = error.localizedDescription } + } + + func reconcile(_ rows: [McpServerRow]) { + let ids = Set(rows.map(\.id)) + for id in Array(states.keys) where !ids.contains(id) { + probes.removeValue(forKey: id)?.cancel(); requests[id] = nil + if let state = states.removeValue(forKey: id) { service?.release(Array(state.policies.values)) } + targets[id] = nil + } + for row in rows where states[row.id] != nil { + let previous = targets.updateValue(row, forKey: row.id) + // Registration changes leave connection identity and inventory intact. + // Refresh local policy without connecting to the server a second time. + if previous?.globalSources != row.globalSources { loadPolicies(row) } + } + } + + func cancelAll() { + for task in probes.values { task.cancel() } + probes.removeAll(); requests.removeAll() + for id in Array(states.keys) { states[id]?.isLoading = false } + } + + func leavePage() { + cancelAll() + for state in states.values { service?.release(Array(state.policies.values)) } + states.removeAll() + targets.removeAll() + } + + func commandLine(_ draft: McpServerDraft) throws -> String { + guard let service else { throw McpLibraryError.unavailable } + return try service.commandLine(command: draft.command, args: draft.arguments) + } + + func updateCommandLine(_ line: String, draft: inout McpServerDraft) throws { + guard let service else { throw McpLibraryError.unavailable } + let parsed = try service.parseCommandLine(line) + draft.setCommand(parsed.command, arguments: parsed.args) + } + + private func target(_ row: McpServerRow) -> McpToolTarget { + McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + } +} diff --git a/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift b/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift index d6e9642935..976141dc3f 100644 --- a/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift +++ b/macos/Sources/Lithe/Core/Ports/McpConfigurationStore.swift @@ -111,17 +111,26 @@ struct McpServerDefinition: Codable, Hashable, Identifiable, Sendable { } } -/// Ledger entry recording that Lithe wrote a complete source entry. +/// Non-secret native filters retained across server removal and re-registration. +struct McpToolFilters: Codable, Hashable, Sendable { + let enabledTools: [String]? + let disabledTools: [String]? +} + +/// Ledger entry recording ownership, or retained filters with no live source hash. struct McpOwnedEntry: Codable, Hashable, Sendable { let name: String let definitionHash: String /// Missing in legacy records, which must be adopted again before automatic writes. let sourceHash: String? + /// Native paths stay distinct when the user switches Agent profile roots. + let toolFilters: [String: McpToolFilters]? - init(name: String, definitionHash: String, sourceHash: String? = nil) { + init(name: String, definitionHash: String, sourceHash: String? = nil, toolFilters: [String: McpToolFilters]? = nil) { self.name = name self.definitionHash = definitionHash self.sourceHash = sourceHash + self.toolFilters = toolFilters } } @@ -140,6 +149,8 @@ struct McpWriteRequest: Encodable, Sendable { let owned: [McpOwnedEntry] /// Full source version shown when editing; independent of the latest ledger. var expectedHash: String? = nil + /// Applied only to a new Codex entry; existing native policy always wins. + var toolFilters: McpToolFilters? = nil } /// Core's plan: the next file text plus whether anything changed. @@ -154,6 +165,8 @@ struct McpWritePlan: Decodable, Sendable { let definitionHash: String? /// Complete source version derived from the planned output, not the draft. let sourceHash: String? + /// Returned only after a successful removal, for the host's durable ledger. + let toolFilters: McpToolFilters? } /// One compare-and-replace operation in an all-or-rollback batch. diff --git a/macos/Sources/Lithe/Core/Ports/McpRegistry.swift b/macos/Sources/Lithe/Core/Ports/McpRegistry.swift new file mode 100644 index 0000000000..625096eb1b --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/McpRegistry.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Public directory metadata. A suggested recipe remains an editable draft. +struct McpRegistryEntry: Decodable, Identifiable, Sendable { + let id: String + let title: String + let summary: String + let version: String + let website: URL? + let draft: McpServerDefinition? + var publisher: String? = nil + var source: McpRegistrySource? = nil + var setup: McpRegistrySetup? = nil +} + +/// Incomplete, release-reviewed templates; never accepted from public metadata. +enum McpRegistrySetup: String, Decodable, Sendable { + case bearerToken = "bearer-token" + case allowedDirectories = "allowed-directories" +} + +enum McpRegistrySource: String, Decodable, Sendable { case featured, registry } +enum McpRegistryRoute: Equatable, Sendable { case system, direct } +enum McpRegistryError: Error { case secureConnection, timeout, offline, invalidResponse } + +struct McpRegistryPage: Decodable, Sendable { + let entries: [McpRegistryEntry] + let nextCursor: String? +} + +@MainActor +protocol McpRegistryProviding { + func featured() throws -> McpRegistryPage + func search(query: String, cursor: String?, route: McpRegistryRoute) async throws -> McpRegistryPage +} diff --git a/macos/Sources/Lithe/Core/Ports/McpTools.swift b/macos/Sources/Lithe/Core/Ports/McpTools.swift new file mode 100644 index 0000000000..c40618aba3 --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/McpTools.swift @@ -0,0 +1,45 @@ +import Foundation + +struct McpToolDescription: Decodable, Equatable, Identifiable, Sendable { + let name: String + let description: String + var id: String { name } +} + +struct McpProbeResult: Decodable, Sendable { let tools: [McpToolDescription] } +struct McpToolTarget: Sendable { + let definition: McpServerDefinition + let files: [McpConfigurationFile] +} +struct McpToolPermission: Decodable, Sendable { + let name: String + let enabled: Bool + let editable: Bool +} + +struct McpToolPolicySnapshot: Sendable { + let id: UUID + let agent: McpAgent + let tools: [McpToolPermission] + let previousSourceHash: String? + let sourceHash: String? +} + +/// Probe transport and native filesystem access remain behind a platform port. +@MainActor +protocol McpToolsManaging: AnyObject { + func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult + func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot + func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot + func commandLine(command: String, args: [String]) throws -> String + func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) + func release(_ snapshots: [McpToolPolicySnapshot]) + func validateRename(_ target: McpToolTarget) throws + /// Called only after the native batch commits, for each renamed source file. + func serverRenamed(from previousName: String, to name: String, in file: McpConfigurationFile) +} + +extension McpToolsManaging { + func validateRename(_ target: McpToolTarget) throws { } + func serverRenamed(from previousName: String, to name: String, in file: McpConfigurationFile) { } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift new file mode 100644 index 0000000000..a2315890dc --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift @@ -0,0 +1,78 @@ +import Foundation +import LitheCoreContracts + +struct RustAgentInstructionsService: AgentInstructionsService { + let core: RustCoreBridge + var sharedDirectory: URL? = nil + private struct Read: Encodable, Sendable { let agentId: String } + private struct Save: Encodable, Sendable { + let agentId: String + let content: String + let expectedPath: String + let expectedRevision: String + } + + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { + try await run("agent.instructions.read", Read(agentId: agent.rawValue)) + } + + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { + try await run("agent.instructions.save", Save(agentId: file.agentId, content: content, + expectedPath: file.path, expectedRevision: file.revision)) + } + + private struct SharedGuard: Encodable, Sendable { + let agentId: String + let path: String + let revision: String + init(_ file: AgentInstructionFile) { agentId = file.agentId; path = file.path; revision = file.revision } + } + private struct SharedRequest: Encodable, Sendable { + let dataDirectory: String + let action: String + var content: String? = nil + var expectedRevision: String? = nil + var files: [SharedGuard]? = nil + var agentId: String? = nil + var enabled: Bool? = nil + var file: SharedGuard? = nil + } + + func readShared() async throws -> SharedInstructionLibrary { + try await run("agent.instructions.shared", SharedRequest(dataDirectory: try directory(), action: "read")) + } + + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + try await run("agent.instructions.shared", SharedRequest(dataDirectory: try directory(), action: "save", + content: content, expectedRevision: library.file.revision, + files: library.agents.compactMap { $0.file.map(SharedGuard.init) })) + } + + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + guard let file = library.agents.first(where: { $0.agentId == agent.rawValue })?.file else { + throw CocoaError(.fileReadUnknown) + } + return try await run("agent.instructions.shared", SharedRequest(dataDirectory: try directory(), action: "setEnabled", + expectedRevision: library.file.revision, agentId: agent.rawValue, enabled: enabled, file: SharedGuard(file))) + } + + private func directory() throws -> String { + guard let sharedDirectory else { throw CocoaError(.featureUnsupported) } + return sharedDirectory.path + } + + private func run(_ command: String, _ payload: Payload) async throws -> Result { + let core = core + let operationID = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operationID) + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await Task.detached(priority: .userInitiated) { + if cancellation.isCancelled { throw CancellationError() } + let result: Swift.Result = core.executeResult( + command: command, payload: payload, operationID: operationID, onEvent: { cancellation.receive($0) }) + return try result.get() + }.value + } onCancel: { cancellation.cancel() } + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift index f4ec18b900..a36c5e4e01 100644 --- a/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift +++ b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift @@ -7,6 +7,41 @@ import LitheRustCore struct RustAgentManagementService: AgentManagementService { let core: RustCoreBridge + private struct SkillsPayload: Encodable, Sendable { + var action = "list" + let agentId: String + let workspace: String? + } + private struct SkillDirectoriesResult: Decodable, Sendable { let directories: [String: String] } + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] { + let result: SkillDirectoriesResult = try await run("agent.skills", SkillsPayload(action: "directories", agentId: agentID, workspace: workspace?.path)) + return result.directories + } + private struct SkillReadPayload: Encodable, Sendable { + let action = "read" + let agentId: String + let source: String + } + private struct SkillReadResult: Decodable, Sendable { let content: String } + func readSkill(at url: URL, agentID: String) async throws -> String { + let result: SkillReadResult = try await run("agent.skills", SkillReadPayload(agentId: agentID, source: url.path)) + return result.content + } + private struct SkillWriteResult: Decodable, Sendable { let path: String } + + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing { + try await run("agent.skills", SkillsPayload(agentId: agentID, workspace: workspace?.path)) + } + + func writeSkill(_ request: AgentSkillWrite) async throws -> URL { + let result: SkillWriteResult = try await run("agent.skills", request) + return URL(fileURLWithPath: result.path) + } + + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing { + try await run("agent.skills", request) + } + private struct StatusPayload: Encodable { let dataDirectory: String } private struct AgentPayload: Encodable { let dataDirectory: String; let agentId: String } private struct InstallResult: Decodable { let installedVersion: String } @@ -56,20 +91,22 @@ struct RustAgentManagementService: AgentManagementService { ) async throws -> Result { let operationID = UUID().uuidString let core = core + let cancellation = RustCoreCancellation(core: core, operationID: operationID) return try await withTaskCancellationHandler { - try await Task.detached(priority: .userInitiated) { - try core.executeResult(command: command, payload: payload, operationID: operationID, onEvent: onProgress.map { callback in - { event in - guard let data = event.data(using: .utf8), + try Task.checkCancellation() + return try await Task.detached(priority: .userInitiated) { + if cancellation.isCancelled { throw CancellationError() } + let result: Swift.Result = core.executeResult( + command: command, payload: payload, operationID: operationID, onEvent: { event in + cancellation.receive(event) + guard let onProgress, let data = event.data(using: .utf8), let decoded = try? JSONDecoder().decode(InstallEvent.self, from: data), decoded.kind == "agentInstallProgress", decoded.operationId == operationID else { return } - callback(decoded.progress) - } - }).get() + onProgress(decoded.progress) + }) + return try result.get() }.value - } onCancel: { - core.cancel(operationID: operationID) - } + } onCancel: { cancellation.cancel() } } } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift b/macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift new file mode 100644 index 0000000000..b54a6a6b77 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Replays an early cancellation at Core's synchronous registration callback. +/// The callback precedes dispatch, so even a short file mutation cannot outrun +/// a timer retry. No registration or worker outlives the enclosing C ABI call. +final class RustCoreCancellation: @unchecked Sendable { + private let lock = NSLock() + private let core: RustCoreBridge + private let operationID: String + private var cancelled = false + + init(core: RustCoreBridge, operationID: String) { + self.core = core + self.operationID = operationID + } + + var isCancelled: Bool { lock.withLock { cancelled } } + + func cancel() { + lock.withLock { cancelled = true } + core.cancel(operationID: operationID) + } + + func receive(_ event: String) { + struct Registration: Decodable { let type: String } + guard let registration = try? JSONDecoder().decode(Registration.self, from: Data(event.utf8)), + registration.type == "requestStarted", isCancelled else { return } + core.cancel(operationID: operationID) + } +} diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 6c18180399..a611051925 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -983,6 +983,7 @@ private struct SettingsWindow: View { viewState: viewState, initialCategory: requestedCategory, categoryRequest: categoryRequest, + skillProjects: projectSessions.openProjects.compactMap(\.workspaceURL), onDismiss: close ) .environmentObject(settingsContext) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift index fc9b6d76f0..cab6a59d3e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+MCP.swift @@ -6,6 +6,8 @@ import Foundation /// `IdeCapabilitiesFeatureModel` forwards the helper command it received so the /// library can write the same server into the project's Agent configuration. extension AppModel: McpServerLibraryActions { + var mcpRegistry: (any McpRegistryProviding)? { services.mcpRegistry } + var mcpTools: (any McpToolsManaging)? { services.mcpTools } var mcpConfigurationStore: (any McpConfigurationStore)? { services.mcpConfigurationStore } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 61fd8c2c27..85a44c8256 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -98,6 +98,8 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { service: services.agentManagement, dataDirectory: agentDataDirectory ) + lazy var agentInstructionsFeature = AgentInstructionsFeatureModel(service: services.agentInstructions) + lazy var agentSkillsFeature = AgentSkillsFeatureModel(service: services.agentManagement) /// Settings › Plugins marketplace and plugin repositories, created when /// the page is first shown. lazy var pluginMarketplaceFeature = PluginMarketplaceFeatureModel( diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 4c456f932f..a628f5cbb0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -12,6 +12,8 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case lsp = "LSP" case ai = "AI & Commit" case agents = "Agents" + case instructions = "Instructions" + case skills = "Skills" case providers = "AI Providers" case git = "Git" case updates = "Updates" @@ -45,6 +47,8 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .lsp: "server.rack" case .ai: "wand.and.stars" case .agents: "sparkles" + case .instructions: "doc.text" + case .skills: "book" case .providers: "key" case .git: "arrow.triangle.branch" case .updates: "arrow.down.circle" @@ -78,7 +82,7 @@ enum SettingsGroup: String, CaseIterable, Identifiable { case .build: [.project, .run] case .languages: [.lsp] case .tools: [.terminal, .diagnostics] - case .ai: [.mcp, .ai, .agents, .providers] + case .ai: [.mcp, .ai, .instructions, .agents, .skills, .providers] } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpRegistry.swift b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpRegistry.swift new file mode 100644 index 0000000000..0a3158f9c2 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpRegistry.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Reads bounded public metadata into memory; no packages, images, credentials, +/// files or server processes are downloaded or launched by discovery. +@MainActor +final class MacMcpRegistry: McpRegistryProviding { + private let core: RustCoreBridge + private let systemSession: URLSession + private let directSession: URLSession + + init(core: RustCoreBridge) { + self.core = core + func configuration() -> URLSessionConfiguration { + let value = URLSessionConfiguration.ephemeral + value.httpShouldSetCookies = false + value.urlCredentialStorage = nil + value.timeoutIntervalForRequest = 15 + value.timeoutIntervalForResource = 20 + return value + } + systemSession = URLSession(configuration: configuration()) + let direct = configuration() + // An explicit page action chooses this route for public directory reads + // only. It does not alter system proxies or any MCP server connection. + direct.connectionProxyDictionary = [:] + directSession = URLSession(configuration: direct) + } + + deinit { + systemSession.invalidateAndCancel() + directSession.invalidateAndCancel() + } + + func featured() throws -> McpRegistryPage { + struct Payload: Encodable {} + return try core.executeResult(command: "mcp.featuredRegistry", payload: Payload()).get() + } + + func search(query: String, cursor: String?, route: McpRegistryRoute) async throws -> McpRegistryPage { + do { return try await fetch(query: query, cursor: cursor, route: route) } + catch let error as URLError { + switch error.code { + case .secureConnectionFailed, .serverCertificateUntrusted, .serverCertificateHasBadDate, + .serverCertificateHasUnknownRoot, .serverCertificateNotYetValid: + throw McpRegistryError.secureConnection + case .timedOut: throw McpRegistryError.timeout + case .notConnectedToInternet, .cannotFindHost, .cannotConnectToHost, .networkConnectionLost: + throw McpRegistryError.offline + default: throw error + } + } + } + + private func fetch(query: String, cursor: String?, route: McpRegistryRoute) async throws -> McpRegistryPage { + var components = URLComponents(string: "https://registry.modelcontextprotocol.io/v0.1/servers")! + components.queryItems = [URLQueryItem(name: "limit", value: "30"), URLQueryItem(name: "version", value: "latest")] + if !query.isEmpty { components.queryItems?.append(URLQueryItem(name: "search", value: query)) } + if let cursor { components.queryItems?.append(URLQueryItem(name: "cursor", value: cursor)) } + let session = route == .direct ? directSession : systemSession + let content = try await Self.readPage(session: session, url: components.url!) + struct Payload: Encodable { let content: String } + return try core.executeResult(command: "mcp.parseRegistry", payload: Payload(content: content)).get() + } + + // Keep per-byte iteration off the UI actor; only the final bounded text + // returns to the main actor for Core normalization. + nonisolated private static func readPage(session: URLSession, url: URL) async throws -> String { + let (bytes, response) = try await session.bytes(from: url) + defer { bytes.task.cancel() } + guard let response = response as? HTTPURLResponse, response.statusCode == 200, + response.expectedContentLength <= 1_048_576 else { throw McpRegistryError.invalidResponse } + var data = Data() + for try await byte in bytes { + try Task.checkCancellation() + guard data.count < 1_048_576 else { throw McpRegistryError.invalidResponse } + data.append(byte) + } + guard let content = String(data: data, encoding: .utf8) else { throw McpRegistryError.invalidResponse } + return content + } + +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift new file mode 100644 index 0000000000..2998b3ae28 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift @@ -0,0 +1,163 @@ +import Foundation + +/// Native policy snapshots use the same guarded writer and backups as server +/// settings. SDK inventory and argv parsing are shared Core/Host operations. +@MainActor +final class MacMcpTools: McpToolsManaging { + private let core: RustCoreBridge + private let store: any McpConfigurationStore + private let ledger: any KeyValueStore + private let claudeSettings: URL + private var snapshots: [UUID: Snapshot] = [:] + private struct Snapshot { + let file: McpConfigurationFile + let content: String? + let rowID: String + let server: String + let tools: [String] + let owned: [String] + let sourceHash: String? + } + private struct PolicyRequest: Encodable { + let source: String + let content: String + let server: String + let tools: [String] + let owned: [String] + var tool: String? = nil + var enabled: Bool? = nil + } + private struct PolicyResult: Decodable { + let content: String + let changed: Bool + let owned: [String] + let tools: [McpToolPermission] + let hasRestrictions: Bool + } + + init(core: RustCoreBridge, store: any McpConfigurationStore, ledger: any KeyValueStore, + storage: any FileStorage, environment: [String: String] = ProcessInfo.processInfo.environment) { + self.core = core; self.store = store; self.ledger = ledger + let directory = environment["CLAUDE_CONFIG_DIR"].flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) } + ?? storage.homeDirectory().appendingPathComponent(".claude") + claudeSettings = directory.appendingPathComponent("settings.json") + } + + func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult { + struct Payload: Encodable, Sendable { + let transport: McpTransport + let command: String? + let args: [String] + let env: [McpKeyValue] + let url: String? + let headers: [McpKeyValue] + } + let payload = Payload(transport: definition.transport, command: definition.command, args: definition.args, + env: definition.env, url: definition.url, headers: definition.headers) + let core = core; let operation = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operation) + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + // Debug SDK/TLS futures exceed the Swift executor's small stack. + // This bounded blocking C ABI call owns a native thread instead. + let worker = Thread { + if cancellation.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + let result: Result = core.executeResult( + command: "mcp.probe", payload: payload, operationID: operation, onEvent: { cancellation.receive($0) }) + continuation.resume(with: result.mapError { $0 as any Error }) + } + worker.name = "Lithe MCP inspection" + worker.qualityOfService = .userInitiated + worker.stackSize = 8 * 1024 * 1024 + worker.start() + } + } onCancel: { cancellation.cancel() } + } + + func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot { + guard let source = target.files.first(where: { $0.agent == agent }) else { throw McpConfigurationError.changedOnDisk } + let native = try store.read(source) + guard let definition = try store.parse(source, content: native ?? "").first(where: { $0.name == target.definition.name }), + definition.definitionHash == target.definition.definitionHash else { throw McpConfigurationError.changedOnDisk } + let file = agent == .codex ? source : McpConfigurationFile(agent: agent, scope: .global, url: claudeSettings, projectPath: nil) + let content = agent == .codex ? native : try store.read(file) + let owned = ledger.stringArray(forKey: key(file, target.definition.name)) ?? [] + let request = PolicyRequest(source: agent.sourceName, content: content ?? "", server: target.definition.name, tools: tools, owned: owned) + let result: PolicyResult = try core.executeResult(command: "mcp.toolPolicy", payload: request).get() + return remember(Snapshot(file: file, content: content, rowID: target.definition.id, server: target.definition.name, tools: tools, owned: owned, + sourceHash: definition.sourceHash), result: result, previous: nil) + } + + func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot { + guard let old = snapshots[snapshot.id], old.rowID == target.definition.id, old.tools.contains(tool) else { throw McpConfigurationError.changedOnDisk } + guard (ledger.stringArray(forKey: key(old.file, old.server)) ?? []) == old.owned else { throw McpConfigurationError.changedOnDisk } + // Recheck the connection too: Claude's deny rules live in another file. + guard let source = target.files.first(where: { $0.agent == snapshot.agent }), + let current = try store.parse(source, content: store.read(source) ?? "").first(where: { $0.name == target.definition.name }), + current.definitionHash == target.definition.definitionHash else { throw McpConfigurationError.changedOnDisk } + let result: PolicyResult = try core.executeResult(command: "mcp.toolPolicy", payload: PolicyRequest( + source: snapshot.agent.sourceName, content: old.content ?? "", server: old.server, + tools: old.tools, owned: old.owned, tool: tool, enabled: enabled)).get() + if result.changed { _ = try store.write(result.content, to: old.file, replacing: old.content) } + else if try store.read(old.file) != old.content { throw McpConfigurationError.changedOnDisk } + ledger.set(result.owned, forKey: key(old.file, old.server)) + let updated = snapshot.agent == .codex ? try store.parse(old.file, content: result.content).first(where: { $0.name == old.server })?.sourceHash : current.sourceHash + snapshots.removeValue(forKey: snapshot.id) + return remember(Snapshot(file: old.file, content: result.changed ? result.content : old.content, rowID: old.rowID, + server: old.server, tools: old.tools, owned: result.owned, sourceHash: updated), result: result, previous: current.sourceHash) + } + + func release(_ values: [McpToolPolicySnapshot]) { for value in values { snapshots.removeValue(forKey: value.id) } } + + func serverRenamed(from previousName: String, to name: String, in file: McpConfigurationFile) { + guard file.agent == .codex, previousName != name else { return } + // The native table already moved atomically with its exact restrictions. + // Move only our names at this profile path, never adopt external rules + // or stale ownership left at the destination by an older server. + let previousKey = key(file, previousName) + ledger.set(ledger.stringArray(forKey: previousKey), forKey: key(file, name)) + ledger.set(nil, forKey: previousKey) + } + + func validateRename(_ target: McpToolTarget) throws { + guard target.files.contains(where: { $0.agent == .claudeCode }) else { return } + let file = McpConfigurationFile(agent: .claudeCode, scope: .global, url: claudeSettings, projectPath: nil) + let result: PolicyResult = try core.executeResult(command: "mcp.toolPolicy", payload: PolicyRequest( + source: "claude", content: store.read(file) ?? "", server: target.definition.name, tools: [], owned: [])).get() + if result.hasRestrictions { throw RenameError.restricted } + } + + private enum RenameError: LocalizedError { + case restricted + var errorDescription: String? { + String(localized: "Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name.") + } + } + + func commandLine(command: String, args: [String]) throws -> String { + struct Payload: Encodable { let action = "format"; let command: String; let args: [String] } + struct Result: Decodable { let line: String } + let result: Result = try core.executeResult(command: "mcp.commandLine", payload: Payload(command: command, args: args)).get() + return result.line + } + + func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) { + struct Payload: Encodable { let action = "parse"; let line: String } + struct Result: Decodable { let command: String; let args: [String] } + let result: Result = try core.executeResult(command: "mcp.commandLine", payload: Payload(line: line)).get() + return (result.command, result.args) + } + + private func remember(_ value: Snapshot, result: PolicyResult, previous: String?) -> McpToolPolicySnapshot { + let id = UUID(); snapshots[id] = value + return McpToolPolicySnapshot(id: id, agent: value.file.agent, tools: result.tools, + previousSourceHash: previous, sourceHash: value.sourceHash) + } + private func key(_ file: McpConfigurationFile, _ name: String) -> String { + "settings.mcpToolExclusions.\(file.agent.rawValue).\(file.url.path).\(name)" + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 301ba6e0ec..7f7109edb7 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -762,6 +762,8 @@ final class MacServiceContainer { diagnosticsExportService: diagnosticsExportService, credentialResolver: credentialResolver, agentManagement: RustAgentManagementService(core: rustCore), + agentInstructions: RustAgentInstructionsService(core: rustCore, + sharedDirectory: fileStorage.applicationSupportDirectory().appendingPathComponent("Lithe/AgentInstructions", isDirectory: true)), agentProviderConfigurationParser: MacAgentProviderConfigurationParser(core: rustCore), aiConfigurationSources: aiConfigurationSources, recentProjectsStore: RecentProjectsStore(store: store), @@ -781,6 +783,10 @@ final class MacServiceContainer { services.mcpConfigurationStore = MacAgentMcpConfigurationStore( core: rustCore, storage: fileStorage ) + services.mcpRegistry = MacMcpRegistry(core: rustCore) + if let configuration = services.mcpConfigurationStore { + services.mcpTools = MacMcpTools(core: rustCore, store: configuration, ledger: services.store, storage: fileStorage) + } services.connectBuildProviderLanguageSessions = { sessions in buildProviderSessions.languageSessions = sessions } diff --git a/macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt b/macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt new file mode 100644 index 0000000000..4ace03ddb6 --- /dev/null +++ b/macos/Sources/Lithe/Resources/McpIcons/LICENSE-Playwright.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/macos/Sources/Lithe/Resources/McpIcons/NOTICE.json b/macos/Sources/Lithe/Resources/McpIcons/NOTICE.json new file mode 100644 index 0000000000..c3a7b237d9 --- /dev/null +++ b/macos/Sources/Lithe/Resources/McpIcons/NOTICE.json @@ -0,0 +1,46 @@ +{ + "reviewed": "2026-10-22", + "purpose": "Publisher brand marks identify featured MCP services. All rights in the marks remain with their owners; no affiliation is implied. These fixed source assets are bundled read-only, never fetched at runtime.", + "assets": { + "context7.png": { + "source": "https://avatars.githubusercontent.com/u/74989412?v=4&s=96", + "publisher": "upstash", + "sha256": "076a02c39aee75f059d4ddd4fccac6350e601b32f8d0659c0cdc907cd7800f4b" + }, + "chrome-devtools.png": { + "source": "https://avatars.githubusercontent.com/u/11260967?v=4&s=96", + "publisher": "ChromeDevTools", + "sha256": "be958c77f44d61b821ce9476c68a5d65a16b52430fafe7160ff1ac83e822caa6" + }, + "microsoft-learn.png": { + "source": "https://avatars.githubusercontent.com/u/6154722?v=4&s=96", + "publisher": "microsoft", + "sha256": "5fd80b0c551b5ad5d40acde0604fc0866bed3cdb0371e10b6aa2930e57bfdf8f" + }, + "github.png": { + "source": "https://avatars.githubusercontent.com/u/9919?v=4&s=96", + "publisher": "github", + "sha256": "4a309065f9744816e3bb1e3e002c4e947929a8d76aeb1acd6f99ec3a2834ffb9" + }, + "deepwiki.png": { + "source": "https://avatars.githubusercontent.com/u/163493330?v=4&s=96", + "publisher": "CognitionAI", + "sha256": "4dd715338111d28fe1f3484a0bf73c04291b367511cd90630a20154b7739c1cd" + }, + "exa.png": { + "source": "https://avatars.githubusercontent.com/u/77906174?v=4&s=96", + "publisher": "exa-labs", + "sha256": "c7fe707b4c5950fb00276bcab65ead9d7ce3c23c1e9e6adae1f0b40ebfd62e88" + }, + "mcp.png": { + "source": "https://avatars.githubusercontent.com/u/182288589?v=4&s=96", + "publisher": "modelcontextprotocol", + "sha256": "a298f5068002e0f8c62e4cb6465f171b641111bc8cde9b8cb52055d1fc7cc102" + }, + "playwright.svg": { + "source": "https://playwright.dev/img/playwright-logo.svg", + "publisher": "Microsoft", + "sha256": "6b0a4367bdeab10995bc239278f04c68c10e48adbec15e799e01909a0d66dcb9" + } + } +} diff --git a/macos/Sources/Lithe/Resources/McpIcons/chrome-devtools.png b/macos/Sources/Lithe/Resources/McpIcons/chrome-devtools.png new file mode 100644 index 0000000000000000000000000000000000000000..11e24797d514cabacf3aff56a70d3fe938242c52 GIT binary patch literal 7315 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 literal 0 HcmV?d00001 diff --git a/macos/Sources/Lithe/Resources/McpIcons/context7.png b/macos/Sources/Lithe/Resources/McpIcons/context7.png new file mode 100644 index 0000000000000000000000000000000000000000..22d42ec2980d452c64b2944e4991f0ad61ac6d9b GIT binary patch literal 3157 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 D(Cmyg literal 0 HcmV?d00001 diff --git a/macos/Sources/Lithe/Resources/McpIcons/playwright.svg b/macos/Sources/Lithe/Resources/McpIcons/playwright.svg new file mode 100644 index 0000000000..7b3ca7d6c9 --- /dev/null +++ b/macos/Sources/Lithe/Resources/McpIcons/playwright.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Sources/Lithe/Theme/McpBrandIcon.swift b/macos/Sources/Lithe/Theme/McpBrandIcon.swift new file mode 100644 index 0000000000..15899a8bf5 --- /dev/null +++ b/macos/Sources/Lithe/Theme/McpBrandIcon.swift @@ -0,0 +1,62 @@ +import AppKit +import SwiftUI + +/// Only reviewed, bundled marks may identify a featured publisher. Registry +/// responses cannot cause image requests or choose arbitrary bundle paths. +@MainActor +enum McpBrandIconLoader { + private static var images: [String: NSImage] = [:] + + static func filename(for entry: McpRegistryEntry) -> String? { + guard entry.source == .featured else { return nil } + switch entry.id { + case "lithe.featured/context7": return "context7.png" + case "lithe.featured/playwright": return "playwright.svg" + case "lithe.featured/chrome-devtools": return "chrome-devtools.png" + case "lithe.featured/microsoft-learn": return "microsoft-learn.png" + case "lithe.featured/github": return "github.png" + case "lithe.featured/deepwiki": return "deepwiki.png" + case "lithe.featured/exa": return "exa.png" + case "lithe.featured/filesystem", "lithe.featured/fetch", "lithe.featured/memory": return "mcp.png" + default: return nil + } + } + + static func image(for entry: McpRegistryEntry) -> NSImage? { + guard let filename = filename(for: entry) else { return nil } + if let image = images[filename] { return image } + // Reuse the packaged/SwiftPM resolver's damaged-app fallback. Assets are + // immutable source inputs, never downloaded or rewritten at runtime. + guard let bundle = AgentBrandIconLoader.resolveResourceBundle(), + let url = bundle.url(forResource: filename, withExtension: nil, subdirectory: "McpIcons"), + let image = NSImage(contentsOf: url) else { return nil } + image.isTemplate = false + image.size = NSSize(width: 28, height: 28) + images[filename] = image + return image + } +} + +struct McpBrandIcon: View { + let entry: McpRegistryEntry + + var body: some View { + Group { + if let image = McpBrandIconLoader.image(for: entry) { + Image(nsImage: image).resizable().renderingMode(.original) + .aspectRatio(contentMode: .fit).padding(4) + // A neutral tile preserves dark publisher marks in dark mode. + .background(Color.white) + } else { + Image(systemName: entry.draft?.transport == .stdio ? "terminal" : "network") + .font(LitheTheme.uiFont(size: 17)).foregroundStyle(LitheTheme.accent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(LitheTheme.accent.opacity(0.08)) + } + } + .frame(width: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(LitheTheme.settingsControlBorder, lineWidth: 0.5)) + .accessibilityHidden(true) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift new file mode 100644 index 0000000000..9d158640be --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift @@ -0,0 +1,231 @@ +import SwiftUI +import LitheCoreContracts + +/// A compact shared library followed by native Agent rows. Only explicit actions write. +struct AgentInstructionsSettingsView: View { + @Environment(\.locale) private var locale + var localizationBundle: Bundle = .main + @ObservedObject var feature: AgentInstructionsFeatureModel + @State private var operations: [UUID: Task] = [:] + private enum Editor: Hashable { case shared, native } + @FocusState private var focusedEditor: Editor? + @State private var expandsShared = false + @State private var confirmsReload = false + @State private var reloadsShared = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 6) { + Text(key("Instructions")).font(LitheTheme.uiFont(size: 16, weight: .semibold)) + LitheInfoTip(key("Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply.")) + Spacer(minLength: 0) + Button(localized("Refresh")) { + run { await feature.loadShared(); await feature.loadAll() } + }.buttonStyle(LitheActionButtonStyle()) + .disabled(feature.isSharedBusy || feature.phases.values.contains(.saving)) + } + sharedCard + LitheSettingsSection(title: localized("Instruction Agents"), accessory: { + LitheInfoTip(key("The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file.")) + }) { + ForEach(AgentInstructionKind.allCases) { agent in + agentRow(agent) + if feature.isEditorExpanded && feature.selectedAgent == agent { nativeEditor } + } + } + } + .font(LitheTheme.settingsFont) + .frame(maxWidth: 860, alignment: .leading) + .task { + await feature.loadShared() + await feature.loadAll() + } + .onDisappear { operations.values.forEach { $0.cancel() }; operations.removeAll() } + .alert(localized("Discard draft and reload?"), isPresented: $confirmsReload) { + Button(localized("Cancel"), role: .cancel) {} + Button(localized("Reload from disk"), role: .destructive) { reload(shared: reloadsShared) } + } message: { Text(key("Reloading replaces this draft with the current file contents.")) } + } + + private var sharedCard: some View { + LitheSettingsCard { + HStack(spacing: 10) { + Image(systemName: "doc.text").foregroundStyle(LitheTheme.secondaryText) + Text(key("Shared instructions")).font(LitheTheme.uiFont(size: 13, weight: .semibold)) + Spacer(minLength: 0) + Text("Markdown").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.tertiaryText) + }.padding(.vertical, 8) + Button { expandsShared.toggle() } label: { + HStack(spacing: 10) { + Image(systemName: "doc.plaintext").foregroundStyle(LitheTheme.accent) + VStack(alignment: .leading, spacing: 4) { + Text(key("Default instructions")).font(LitheTheme.uiFont(size: 13, weight: .medium)) + Text(verbatim: feature.sharedDraft.split(whereSeparator: \.isNewline).first.map(String.init) ?? localized("Empty")) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText).lineLimit(1) + } + Spacer(minLength: 0) + if feature.isSharedDirty { badge("Unsaved changes", color: LitheTheme.warning) } + Image(systemName: expandsShared ? "chevron.down" : "chevron.right") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.tertiaryText) + }.padding(.vertical, 8).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("shared-instructions-expand") + if expandsShared { + VStack(alignment: .leading, spacing: 10) { + TextEditor(text: Binding(get: { feature.sharedDraft }, set: feature.editShared)) + .font(LitheTheme.uiFont(size: 12, design: .monospaced)).autocorrectionDisabled() + .litheSettingsTextEditor(height: 230) + .focused($focusedEditor, equals: .shared) + .disabled(feature.sharedLibrary == nil || feature.isSharedBusy) + .accessibilityLabel(Text(key("Shared instructions"))) + ViewThatFits(in: .horizontal) { + HStack(spacing: LitheTheme.ActionButton.spacing) { sharedActions } + VStack(alignment: .leading, spacing: LitheTheme.ActionButton.spacing) { sharedActions } + } + }.padding(.vertical, 8) + } + if case .failed(let message) = feature.sharedPhase { + HStack { + Text(key(String.LocalizationValue(message))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + Spacer(minLength: 0) + Button(localized("Reload from disk")) { requestReload(shared: true) } + .buttonStyle(LitheActionButtonStyle()).disabled(feature.isSharedBusy) + } + } + } + } + + @ViewBuilder private var sharedActions: some View { + Button(localized("Save shared instructions")) { run { await feature.saveShared() } } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .disabled(!feature.canSaveShared).opacity(feature.canSaveShared ? 1 : 0.5) + .keyboardShortcut(focusedEditor == .shared ? KeyboardShortcut("s", modifiers: .command) : nil) + Button(localized("Reload from disk")) { requestReload(shared: true) } + .buttonStyle(LitheActionButtonStyle()).disabled(feature.isSharedBusy) + if feature.isSharedBusy { ProgressView().controlSize(.small) } + else if feature.sharedSaved { Text(key("Shared instructions saved.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + if feature.sharedDraft.utf8.count > 1024 * 1024 { + Text(key("Global instructions must be no larger than 1 MiB.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + } + } + + private func agentRow(_ agent: AgentInstructionKind) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Button { feature.toggleEditor(agent) } label: { + HStack(spacing: 12) { + AgentBrandIcon(name: agent.title, size: 24, style: .brand) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: agent.title).font(LitheTheme.uiFont(size: 13, weight: .medium)) + if let file = feature.files[agent] { + Text(verbatim: file.path).font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText).lineLimit(1).truncationMode(.middle).help(file.path) + } + } + Spacer(minLength: 0) + }.contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("instruction-agent-\(agent.rawValue)") + if let state = feature.sharedState(agent) { + if state.needsSync { badge("Sync needed", color: LitheTheme.warning) } + else if state.ownLineCount > 0 { + Text(verbatim: String(format: localized("%d lines of its own"), state.ownLineCount)) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + if let enabled = state.enabled { + Toggle(localized("Use shared instructions"), isOn: Binding(get: { enabled }, set: { value in + run { await feature.setSharedEnabled(value, agent: agent) } + })) + .toggleStyle(.switch).controlSize(.small).labelsHidden() + .accessibilityLabel(Text(verbatim: agent.title + ": " + localized("Use shared instructions"))) + .disabled(!feature.canToggleShared(agent)) + } + } + Button { feature.toggleEditor(agent) } label: { + Image(systemName: feature.isEditorExpanded && feature.selectedAgent == agent ? "chevron.down" : "chevron.right") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.tertiaryText) + }.buttonStyle(.plain).accessibilityLabel(Text(verbatim: agent.title)) + }.padding(.vertical, 9) + if let issue = feature.sharedSyncErrors[agent] ?? feature.sharedState(agent)?.issue { + Text(key(String.LocalizationValue(issue))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + } + if feature.sharedState(agent)?.needsSync == true { + Button(localized("Sync again")) { run { await feature.setSharedEnabled(true, agent: agent) } } + .buttonStyle(LitheActionButtonStyle()).disabled(!feature.canToggleShared(agent)) + } + if feature.files[agent] == nil, case .failed(let message) = feature.phases[agent] { + Text(key(String.LocalizationValue(message))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + } + } + } + + private var nativeEditor: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Text(key("Native instruction file")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip(key("This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents.")) + Spacer(minLength: 0) + } + if let file = feature.file { + if file.path.hasSuffix("AGENTS.override.md") { + Text(key("Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions.")) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning).fixedSize(horizontal: false, vertical: true) + } + if !file.exists { + Text(key("The file will be created when you save.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + TextEditor(text: Binding(get: { feature.draft }, set: feature.edit)) + .font(LitheTheme.uiFont(size: 12, design: .monospaced)).autocorrectionDisabled() + .litheSettingsTextEditor(height: 280) + .focused($focusedEditor, equals: .native).disabled(feature.isBusy || feature.isSharedBusy) + .accessibilityLabel(Text(key("Global instructions"))) + } + ViewThatFits(in: .horizontal) { + HStack(spacing: LitheTheme.ActionButton.spacing) { nativeActions } + VStack(alignment: .leading, spacing: LitheTheme.ActionButton.spacing) { nativeActions } + } + if case .failed(let message) = feature.phase { + Text(key(String.LocalizationValue(message))).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error).textSelection(.enabled) + } + if feature.draft.utf8.count > 1024 * 1024 { + Text(key("Global instructions must be no larger than 1 MiB.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + } + }.padding(.vertical, 8) + } + + @ViewBuilder private var nativeActions: some View { + Button(localized("Save instructions")) { run { await feature.save() } } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .disabled(!feature.canSave).opacity(feature.canSave ? 1 : 0.5) + .keyboardShortcut(focusedEditor == .native ? KeyboardShortcut("s", modifiers: .command) : nil) + Button(localized("Reload from disk")) { requestReload(shared: false) } + .buttonStyle(LitheActionButtonStyle()).disabled(feature.isBusy || feature.isSharedBusy) + if feature.isBusy { ProgressView().controlSize(.small) } + else if feature.isDirty { Text(key("Unsaved changes")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning) } + else if feature.saved.contains(feature.selectedAgent) { Text(key("Instructions saved.")).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + } + + private func badge(_ value: String.LocalizationValue, color: Color) -> some View { + Text(key(value)).font(LitheTheme.smallFont).foregroundStyle(color) + } + private func run(_ action: @escaping @MainActor () async -> Void) { + let id = UUID() + operations[id] = Task { + await action() + operations.removeValue(forKey: id) + } + } + private func requestReload(shared: Bool) { + reloadsShared = shared + if shared ? feature.isSharedDirty : feature.isDirty { confirmsReload = true } + else { reload(shared: shared) } + } + private func reload(shared: Bool) { + run { + if shared { await feature.loadShared(reload: true) } + else { await feature.load(reload: true) } + } + } + private func localized(_ value: String.LocalizationValue) -> String { + AgentLocalization.string(value, locale: locale, bundle: localizationBundle) + } + private func key(_ value: String.LocalizationValue) -> LocalizedStringKey { LocalizedStringKey(localized(value)) } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift index d401065017..f9afb77ab2 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift @@ -53,6 +53,8 @@ private struct AgentsManagementPage: View { statisticsRow } agentPicker + Button("Manage Skills…") { model.showSettings(category: .skills) } + .buttonStyle(LitheActionButtonStyle()) checkFeedback switch selectedEntry { case .agent(let id): diff --git a/macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift b/macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift new file mode 100644 index 0000000000..3a0cfde0f2 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift @@ -0,0 +1,112 @@ +import SwiftUI +import LitheCoreContracts + +struct AgentSkillDetailSheet: View { + let item: AgentSkillsFeatureModel.Item + @ObservedObject var feature: AgentSkillsFeatureModel + let onOpen: () -> Void + let onReveal: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var content: String? + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(verbatim: item.skill.name).font(LitheTheme.uiFont(size: 16, weight: .semibold)) + if let plugin = item.skill.pluginId { Text(verbatim: plugin).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + Text(verbatim: item.agentName).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + Text(verbatim: item.skill.description).font(LitheTheme.settingsFont).textSelection(.enabled) + Text(verbatim: item.skill.path).font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText).textSelection(.enabled) + if item.source == "directory" { + Text("File inventory only. Claude determines whether this skill is available in a session.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } else if let enabled = item.skill.enabled { + Text(enabled ? "Enabled in Agent" : "Disabled in Agent").font(LitheTheme.smallFont) + } + if let error { AgentSettingsIssue(error) } + if let content { + ScrollView { + Text(verbatim: content).font(LitheTheme.codeFont).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading).padding(8) + }.litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) + } else if error == nil { ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) } + HStack(spacing: LitheTheme.ActionButton.spacing) { + Button("Open SKILL.md", action: onOpen).buttonStyle(LitheActionButtonStyle()) + Button("Reveal in Finder", action: onReveal).buttonStyle(LitheActionButtonStyle()) + Spacer() + Button("Close") { dismiss() }.buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) + } + } + .padding(20).frame(width: 560, height: 570).background(LitheTheme.settingsSurface) + .task(id: item.id) { + do { + let result = try await feature.read(item) + if !Task.isCancelled { content = result } + } catch { if !Task.isCancelled { self.error = error.localizedDescription } } + } + } +} + +struct AgentSkillCreateSheet: View { + @ObservedObject var feature: AgentSkillsFeatureModel + let context: AgentSkillsFeatureModel.Context + let scope: String + let initialAgentID: String + let source: URL? + @Environment(\.dismiss) private var dismiss + @State private var agentID = "codex-acp" + @State private var name = "" + @State private var description = "" + @State private var instructions = "" + + private var valid: Bool { + source != nil || (!name.isEmpty && !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(source == nil ? "New skill" : "Import skill").font(LitheTheme.uiFont(size: 16, weight: .semibold)) + HStack(spacing: 8) { + Text("Agent") + LitheSettingsSelect(selection: $agentID, options: AgentSkillsFeatureModel.agents, width: 180, + accessibilityLabel: "Skill target Agent", title: { $0 == "codex-acp" ? "Codex" : "Claude" }, localizesTitles: false) + Text(scope == "repo" ? "Project skills" : "Global skills").foregroundStyle(LitheTheme.secondaryText) + } + if let directory = feature.directory(agentID: agentID, scope: scope) { + Text(verbatim: directory).font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText).textSelection(.enabled) + } + if let source { + Text("Import source").font(LitheTheme.smallFont) + Text(verbatim: source.path).font(LitheTheme.codeFont).textSelection(.enabled) + Text("Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } else { + TextField("Skill name (lowercase-with-hyphens)", text: $name).litheSettingsTextField() + TextField("When should this skill be used?", text: $description).litheSettingsTextField() + Text("Skill instructions").font(LitheTheme.smallFont) + TextEditor(text: $instructions).font(LitheTheme.codeFont).litheSettingsTextEditor(height: 230) + } + if let error = feature.error { AgentSettingsIssue(error) } + HStack(spacing: LitheTheme.ActionButton.spacing) { + if feature.isWriting { ProgressView().controlSize(.small) } + Spacer() + Button("Cancel") { dismiss() }.buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) + Button(source == nil ? "Create skill" : "Import") { + let request: AgentSkillWrite + if let source { request = .init(agentID: agentID, workspace: context.workspace, scope: scope, source: source) } + else { request = .init(agentID: agentID, workspace: context.workspace, scope: scope, name: name, + description: description, instructions: instructions) } + feature.write(request, context: context) + }.buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .disabled(!valid || feature.phase == .loading) + } + } + .padding(20).frame(width: 540, height: source == nil ? 490 : 300) + .background(LitheTheme.settingsSurface).disabled(feature.isWriting) + .interactiveDismissDisabled(feature.isWriting) + .onAppear { agentID = initialAgentID } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift new file mode 100644 index 0000000000..a84f07f893 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift @@ -0,0 +1,181 @@ +import SwiftUI +import LitheCoreContracts + +/// Scope is the main navigation; Agent provenance is secondary filtering. +struct AgentSkillsSettingsView: View { + @ObservedObject var feature: AgentSkillsFeatureModel + let workspace: URL? + var openProjects: [URL] = [] + var preferredAgentID: String? + let onOpen: (URL) -> Void + let onReveal: (URL) -> Void + let onCopy: (String) -> Void + let onChooseImport: (Bool, String, String) -> URL? + @Environment(\.locale) private var locale + @State private var selectedProjectPath = "" + @State private var sheet: AgentSkillSheet? + + private var projectPaths: [String] { + var paths = openProjects.map(\.path) + if let path = workspace?.path { paths.insert(path, at: 0) } + return paths.reduce(into: []) { if !$0.contains($1) { $0.append($1) } } + } + private var context: AgentSkillsFeatureModel.Context { + let path = projectPaths.contains(selectedProjectPath) ? selectedProjectPath : projectPaths.first + return .init(workspace: path.map { URL(fileURLWithPath: $0) }) + } + private var busy: Bool { feature.isWriting || feature.phase == .loading } + private var canAdd: Bool { !busy && (feature.scope != "repo" || context.workspace != nil) } + private func text(_ key: String) -> String { AgentLocalization.message(key, locale: locale) } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Text("Skills").font(LitheTheme.uiFont(size: 16, weight: .semibold)) + LitheInfoTip("Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions.") + Spacer(minLength: 0) + if !projectPaths.isEmpty { + LitheSettingsSelect(selection: Binding(get: { context.workspace?.path ?? "" }, set: { selectedProjectPath = $0 }), + options: projectPaths, width: 190, accessibilityLabel: "Skill project", title: { URL(fileURLWithPath: $0).lastPathComponent }, localizesTitles: false) + .disabled(feature.isWriting) + } + } + LitheSettingsSegmentedControl(selection: $feature.scope, options: ["user", "repo"], width: 300, + title: { text($0 == "user" ? "Global skills" : "Project skills") + " \(feature.count(in: $0))" }) + .disabled(feature.isWriting) + LitheSearchTextField("Search skills", text: $feature.query).litheSearchField() + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { toolbar } + VStack(alignment: .leading, spacing: 8) { toolbar } + } + feedback + if feature.scope == "repo" && context.workspace == nil { + Text("Open a project to manage its skills.").foregroundStyle(LitheTheme.secondaryText).padding(.vertical, 16) + } else if feature.phase != .loading && feature.visibleItems.isEmpty { + Text(feature.query.isEmpty ? "No skills in this scope." : "No matching skills.") + .foregroundStyle(LitheTheme.secondaryText).padding(.vertical, 16) + } + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(feature.visibleItems) { item in row(item) } + } + } + .font(LitheTheme.settingsFont) + .frame(maxWidth: 760, alignment: .leading) + .task(id: context) { await feature.refresh(context) } + .onChange(of: context) { _ in sheet = nil } + .onChange(of: workspace) { _ in selectedProjectPath = workspace?.path ?? "" } + .onChange(of: feature.savedURL) { url in if url != nil { sheet = nil } } + .onDisappear { feature.cancel() } + .sheet(item: $sheet) { sheet in + switch sheet { + case .details(let item): + AgentSkillDetailSheet(item: item, feature: feature, + onOpen: { open(item.skill.path) }, onReveal: { reveal(item.skill.path) }) + case .add(let source): + AgentSkillCreateSheet(feature: feature, context: context, scope: feature.scope, + initialAgentID: initialAgentID, source: source) + } + } + } + + private var initialAgentID: String { + if AgentSkillsFeatureModel.agents.contains(feature.agentFilter) { return feature.agentFilter } + return AgentSkillsFeatureModel.agents.contains(preferredAgentID ?? "") ? preferredAgentID! : "codex-acp" + } + + @ViewBuilder private var toolbar: some View { + LitheSettingsSelect(selection: $feature.agentFilter, options: ["all"] + AgentSkillsFeatureModel.agents, + width: 140, accessibilityLabel: "Skill source", title: { $0 == "all" ? text("All sources") : ($0 == "codex-acp" ? "Codex" : "Claude") }, localizesTitles: false) + HStack(spacing: LitheTheme.ActionButton.spacing) { + Button { feature.reload(context) } label: { + LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: LitheTheme.Metrics.toolbarIconSize, + fallbackSystemImage: "arrow.clockwise", preservesOriginalColors: true) + }.litheToolbarIconButton().help("Refresh skills").disabled(busy) + LitheMenu { + LitheContextMenuItem.action("Import SKILL.md") { chooseImport(directory: false) } + LitheContextMenuItem.action("Import skill directory") { chooseImport(directory: true) } + } label: { Text("Import") }.buttonStyle(LitheActionButtonStyle()).disabled(!canAdd) + Button("New skill") { feature.dismissResult(); sheet = .add(nil) } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)).disabled(!canAdd) + } + } + + @ViewBuilder private var feedback: some View { + if feature.phase == .loading { ProgressView().controlSize(.small) } + Text("Switches save native Agent settings. Reconnect existing conversations to refresh available skills.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + Text("Skill setting saved.").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + .opacity(feature.didChangeEnablement ? 1 : 0) + .accessibilityHidden(!feature.didChangeEnablement) + if feature.savedURL != nil { + Text("Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation.") + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + if let error = feature.error { AgentSettingsIssue(text(error)) } + ForEach(AgentSkillsFeatureModel.agents, id: \.self) { agentID in + if feature.agentFilter == "all" || feature.agentFilter == agentID { + if let error = feature.discoveryErrors[agentID] { + Text(verbatim: (agentID == "codex-acp" ? "Codex: " : "Claude: ") + text(error)) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning).textSelection(.enabled) + } + ForEach(Array((feature.listings[agentID]?.issues ?? []).enumerated()), id: \.offset) { _, issue in + Text(verbatim: text(issue)).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.warning).textSelection(.enabled) + } + } + } + } + + private func row(_ item: AgentSkillsFeatureModel.Item) -> some View { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Text(verbatim: item.skill.name).font(LitheTheme.uiFont(size: 13, weight: .medium)).lineLimit(1) + AgentBadge(text: LocalizedStringKey(item.agentName), color: LitheTheme.secondaryText) + if item.skill.pluginId != nil { Text("Plugin").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + if item.skill.scope == "system" { Text("Built-in").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + if item.skill.scope == "admin" { Text("Managed").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) } + } + Text(verbatim: item.skill.description).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText).lineLimit(2) + }.frame(maxWidth: .infinity, alignment: .leading) + if let enabled = feature.displayedEnablement(for: item) { + Toggle("Enable skill", isOn: Binding(get: { enabled }, set: { feature.setEnabled($0, for: item, context: context) })) + .toggleStyle(.switch).controlSize(.small).labelsHidden() + .accessibilityLabel(Text(verbatim: text("Enable skill") + ": " + item.skill.name)) + .help(item.agentID == "codex-acp" + ? "Codex saves this setting by path for all projects using this skill." + : "Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting.") + .disabled(!feature.canChangeEnablement(for: item)) + } else { + Text("State unknown").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + Button("View skill") { sheet = .details(item) }.buttonStyle(LitheActionButtonStyle()) + LitheMenu { + LitheContextMenuItem.action("Open SKILL.md") { open(item.skill.path) } + LitheContextMenuItem.action("Reveal in Finder") { reveal(item.skill.path) } + LitheContextMenuItem.action("Copy path") { onCopy(item.skill.path) } + } label: { + LitheIDEAIcon(resourcePath: "expui/general/more.svg", size: LitheTheme.Metrics.toolbarIconSize, + fallbackSystemImage: "ellipsis", preservesOriginalColors: true) + }.litheToolbarIconButton().accessibilityLabel("Skill actions") + } + .padding(10) + .background(LitheTheme.settingsControlBackground) + .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) + } + + private func chooseImport(directory: Bool) { + let title = text(directory ? "Import skill directory" : "Import skill") + let source = onChooseImport(directory, title, text("Import")) + if let source { feature.dismissResult(); sheet = .add(source) } + } + private func open(_ path: String) { onOpen(URL(fileURLWithPath: path)) } + private func reveal(_ path: String) { onReveal(URL(fileURLWithPath: path)) } +} + +private enum AgentSkillSheet: Identifiable { + case details(AgentSkillsFeatureModel.Item) + case add(URL?) + var id: String { + switch self { case .details(let item): "details:" + item.id; case .add(let source): "add:" + (source?.path ?? "new") } + } +} diff --git a/macos/Sources/Lithe/Views/App/McpDiscoveryView.swift b/macos/Sources/Lithe/Views/App/McpDiscoveryView.swift new file mode 100644 index 0000000000..31b4fee1b0 --- /dev/null +++ b/macos/Sources/Lithe/Views/App/McpDiscoveryView.swift @@ -0,0 +1,213 @@ +import SwiftUI + +/// Public directory search is separate from local Agent configuration. Adding +/// opens the normal editor and never starts a server or writes configuration. +struct McpDiscoveryView: View { + @ObservedObject var feature: McpRegistryFeatureModel + let add: (McpServerDraft) -> Void + @State private var query = "" + @State private var request = 0 + @State private var loadMore = false + @State private var route = McpRegistryRoute.system + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Text("Discover MCP servers").font(LitheTheme.settingsFont) + LitheInfoTip("Featured servers ship with Lithe and work offline. Searching also checks the official MCP Registry. Listing is not a security review. Adding opens a configuration to review; packages are not downloaded or run. Follow the publisher's documentation for authentication and other requirements.") + Spacer() + if feature.isLoading { ProgressView().controlSize(.small) } + Text("Featured and the official MCP Registry") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.tertiaryText) + } + HStack(spacing: 8) { + LitheSettingsSearchField("Search MCP servers — GitHub, browsers, documentation…", text: $query) + .onSubmit { search() } + .onChange(of: query) { value in + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { search() } + } + Button("Search") { search() } + .buttonStyle(LitheActionButtonStyle()) + } + if route == .direct { + HStack(spacing: 6) { + Text("Direct connection").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip("Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled.") + Spacer() + Button("Use system proxy") { route = .system; search() } + .buttonStyle(.plain).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.accent).lithePointer() + } + } + if let error = feature.error { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: "wifi.exclamationmark").foregroundStyle(LitheTheme.secondaryText) + Text(error).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + HStack(spacing: LitheTheme.ActionButton.spacing) { + if !feature.entries.isEmpty { + Text("Showing available matches.").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 0) + Button("Retry") { search() }.buttonStyle(LitheActionButtonStyle()) + if feature.canRetryDirectly { + Button("Retry directly") { route = .direct; search() } + .buttonStyle(LitheActionButtonStyle()) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, minHeight: 68, alignment: .leading) + .background(LitheTheme.settingsControlBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else if !feature.isLoading && feature.entries.isEmpty { + Text("No matching MCP servers").font(LitheTheme.settingsFont) + .foregroundStyle(LitheTheme.secondaryText).padding(.vertical, 20) + } + McpRegistryCardsView(entries: feature.entries, add: add) + if feature.nextCursor != nil { + Button("Load more") { loadMore = true; request += 1 } + .buttonStyle(LitheActionButtonStyle()).disabled(feature.isLoading) + } + } + .task(id: request) { await feature.search(query, loadMore: loadMore, route: route) } + } + + private func search() { loadMore = false; request += 1 } +} + +struct McpRegistryCardsView: View { + let entries: [McpRegistryEntry] + let add: (McpServerDraft) -> Void + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: 12)], alignment: .leading, spacing: 12) { + ForEach(entries) { entry in McpRegistryCard(entry: entry, add: add) } + } + } +} + +/// Registry descriptions are primary catalog content, rather than settings help. +/// Two lines let users compare servers without opening every documentation link. +private struct McpRegistryCard: View { + let entry: McpRegistryEntry + let add: (McpServerDraft) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + McpBrandIcon(entry: entry) + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: entry.title) + .font(LitheTheme.uiFont(size: 13, weight: .semibold)).lineLimit(2) + HStack(spacing: 6) { + Text(verbatim: entry.publisher ?? entry.id.components(separatedBy: "/").first ?? entry.id) + .lineLimit(1) + if let transportLabel { + Text(transportLabel) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(LitheTheme.settingsControlBorder.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + if let message = McpServerDraft(entry: entry)?.setupMessage { + Image(systemName: entry.setup == .bearerToken ? "key" : "folder") + .help(message) + } + } + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 0) + } + summary + .font(LitheTheme.uiFont(size: 12)).foregroundStyle(LitheTheme.secondaryText) + .lineLimit(2).frame(maxWidth: .infinity, minHeight: 32, alignment: .topLeading) + .help(summary) + HStack(spacing: 8) { + if entry.source == .featured { + Text("Featured").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + if !entry.version.isEmpty { + Text(verbatim: entry.version).font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.tertiaryText).lineLimit(1) + } + Spacer(minLength: 0) + if let website = entry.website { + Link("Documentation", destination: website).font(LitheTheme.smallFont) + .fixedSize() + } + if let draft = McpServerDraft(entry: entry) { + Button("Configure…") { add(draft) }.buttonStyle(LitheActionButtonStyle()) + .fixedSize() + } + } + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading) + .background(LitheTheme.settingsControlBackground) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(LitheTheme.settingsControlBorder, lineWidth: 1)) + } + + private var summary: Text { + entry.source == .featured ? Text(LocalizedStringKey(entry.summary)) : Text(verbatim: entry.summary) + } + + private var transportLabel: String? { + guard let draft = entry.draft else { return nil } + guard draft.transport == .stdio else { return String(localized: "Remote") } + switch draft.command { + case "npx": return "npx" + case "uvx": return "uvx" + default: return String(localized: "Command") + } + } +} + +/// Pasting a standard mcpServers object previews each recognized definition. +/// The user chooses a server, reviews its fields, then explicitly saves it. +struct McpJsonImportView: View { + let feature: McpServerLibraryFeatureModel + let cancel: () -> Void + let select: (McpServerDraft) -> Void + @State private var json = "" + @State private var drafts: [McpServerDraft] = [] + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Import MCP configuration").font(LitheTheme.uiFont(size: 16, weight: .semibold)) + LitheInfoTip("Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry.") + Spacer() + } + Divider() + TextEditor(text: $json).font(LitheTheme.uiFont(size: 12, design: .monospaced)) + .litheSettingsTextEditor(height: 240) + .accessibilityLabel(Text("MCP JSON")) + .onChange(of: json) { _ in drafts = []; error = nil } + if let error { Text(error).foregroundStyle(LitheTheme.error).font(LitheTheme.smallFont) } + ScrollView { + VStack(spacing: 8) { + ForEach(drafts.indices, id: \.self) { index in + Button { select(drafts[index]) } label: { + HStack { Text(verbatim: drafts[index].name); Spacer(); Text("Review configuration"); Image(systemName: "chevron.right") } + }.buttonStyle(LitheActionButtonStyle()) + } + } + } + Spacer(minLength: 0) + Divider() + HStack(spacing: LitheTheme.ActionButton.spacing) { + Spacer() + Button("Cancel", action: cancel).buttonStyle(LitheActionButtonStyle()) + .keyboardShortcut(.cancelAction) + Button("Preview import") { + do { drafts = try feature.importedDrafts(json); error = nil } + catch { self.error = String(localized: "Could not import MCP JSON. Check the mcpServers object and try again.") } + }.buttonStyle(LitheActionButtonStyle(isPrimary: true)) + } + } + } +} diff --git a/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift b/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift index 343d7cf53a..e26103c5ef 100644 --- a/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift +++ b/macos/Sources/Lithe/Views/App/McpServerLibraryView.swift @@ -11,45 +11,65 @@ struct McpServerLibraryView: View { let workspaceURL: URL? @State private var editing: McpServerDraft? + @State private var showsImporter = false + @State private var query = "" + @Environment(\.locale) private var locale var body: some View { - VStack(alignment: .leading, spacing: 10) { - if editing != nil { - McpServerEditorView(feature: feature, draft: editorBinding) { - editing = nil - } - } else { - header - if let error = feature.error { - LitheBanner(status: .warning) { - Text(error) - .font(LitheTheme.smallFont) - .textSelection(.enabled) - } + VStack(alignment: .leading, spacing: 20) { + header + if let error = feature.error { + LitheBanner(status: .warning) { + Text(error) + .font(LitheTheme.smallFont) + .textSelection(.enabled) } - if !feature.loadErrors.isEmpty { - LitheBanner(status: .warning) { - VStack(alignment: .leading, spacing: 2) { - ForEach(feature.loadErrors, id: \.self) { message in - Text(message).font(LitheTheme.smallFont) - } + } + if !feature.loadErrors.isEmpty { + LitheBanner(status: .warning) { + VStack(alignment: .leading, spacing: 2) { + ForEach(feature.loadErrors, id: \.self) { message in + Text(message).font(LitheTheme.smallFont) } } } - librarySection - if let backup = feature.lastBackupURL { - backupLine(backup) + } + librarySection + if let backup = feature.lastBackupURL { + backupLine(backup) + } + McpDiscoveryView(feature: feature.discovery) { editing = $0 } + Divider() + McpSettingsView(feature: access, library: feature, workspaceURL: workspaceURL) + } + .frame(maxWidth: .infinity, alignment: .leading) + .sheet(isPresented: Binding(get: { editing != nil || showsImporter }, set: { + if !$0 { editing = nil; showsImporter = false; feature.error = nil } + })) { + Group { + if showsImporter { + McpJsonImportView(feature: feature, cancel: closeEditor) { draft in + showsImporter = false + editing = draft + } + } else { + McpServerEditorView(feature: feature, draft: editorBinding, onClose: closeEditor) } - McpSettingsView( - feature: access, - library: feature, - workspaceURL: workspaceURL - ) } + .padding(20) + .frame(width: 640, height: showsImporter ? 580 : 500) + .background(LitheTheme.settingsSurface) + .environment(\.locale, locale) } - .frame(maxWidth: 760, alignment: .leading) .onAppear { feature.refreshIfNeeded() } .onChange(of: workspaceURL) { _ in feature.refresh() } + .onDisappear { feature.tools.leavePage() } + } + + private func closeEditor() { + editing = nil + showsImporter = false + feature.error = nil } private var editorBinding: Binding { @@ -60,34 +80,38 @@ struct McpServerLibraryView: View { } private var header: some View { - HStack(alignment: .top, spacing: 10) { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(LitheTheme.accent.opacity(0.12)) - .frame(width: 32, height: 32) - .overlay { - Image(systemName: "server.rack") - .font(LitheTheme.uiFont(size: 14, weight: .semibold)) - .foregroundStyle(LitheTheme.accent) - } - - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 4) { - Text("MCP servers") - .font(LitheTheme.uiFont(size: 15, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - LitheInfoTip("Lithe reads the servers registered in your Codex and Claude Code configuration. Saving writes only the changed entry, keeps backups in Application Support, and never overwrites an entry created outside Lithe. Agents read MCP configuration when a session starts, so restart a session to apply changes.") - } + HStack(spacing: 10) { + HStack(spacing: 6) { + Text("MCP servers") + .font(LitheTheme.uiFont(size: 17, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + LitheInfoTip("Lithe reads the servers registered in your Codex and Claude Code configuration. Saving writes only the changed entry, keeps backups in Application Support, and never overwrites an entry created outside Lithe. Agents read MCP configuration when a session starts, so restart a session to apply changes.") } - Spacer(minLength: 12) - - Button("Refresh") { feature.refresh() } - .buttonStyle(LitheActionButtonStyle()) + HStack(spacing: LitheTheme.ActionButton.spacing) { + Button("Import JSON") { showsImporter = true } + .buttonStyle(LitheActionButtonStyle()) + Button("Add server") { editing = feature.makeDraft() } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + } } } private var librarySection: some View { - LitheSettingsSection("Server library") { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Server library").font(LitheTheme.settingsFont).foregroundStyle(LitheTheme.secondaryText) + Text(verbatim: "\(feature.rows.count)").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.tertiaryText) + Spacer() + Button("Refresh") { feature.refresh() } + .buttonStyle(.plain) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .lithePointer() + } + if feature.rows.count > 5 || !query.isEmpty { + LitheSettingsSearchField("Search MCP servers", text: $query) + } VStack(alignment: .leading, spacing: 8) { if !feature.isAvailable { Text("MCP configuration management is unavailable.") @@ -95,9 +119,13 @@ struct McpServerLibraryView: View { .foregroundStyle(LitheTheme.secondaryText) } else if feature.rows.isEmpty { emptyState + } else if filteredRows.isEmpty { + Text("No matching MCP servers") + .font(LitheTheme.settingsFont).foregroundStyle(LitheTheme.secondaryText) + .padding(16) } else { VStack(spacing: 6) { - ForEach(feature.rows) { row in + ForEach(filteredRows) { row in McpServerRowView(feature: feature, row: row) { editing = feature.draft(for: row) } @@ -105,40 +133,31 @@ struct McpServerLibraryView: View { } } - if feature.isAvailable { - Button { - editing = feature.makeDraft() - } label: { - Label("Add server", systemImage: "plus") - .font(LitheTheme.uiFont(size: 12, weight: .medium)) - } - .buttonStyle(LitheActionButtonStyle()) - } } } } + private var filteredRows: [McpServerRow] { + feature.rows.filter { + query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) + || $0.summary.localizedCaseInsensitiveContains(query) + } + } + private var emptyState: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "tray") - .font(LitheTheme.uiFont(size: 15)) - .foregroundStyle(LitheTheme.tertiaryText) - .frame(width: 22) - - VStack(alignment: .leading, spacing: 2) { - Text("No MCP servers registered") - .font(LitheTheme.uiFont(size: 12.5, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - Text("Add one here, or register a server with Codex or Claude Code and refresh.") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer(minLength: 0) + VStack(spacing: 8) { + Text("No MCP servers registered") + .font(LitheTheme.uiFont(size: 12.5, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + Text("Add one here, or register a server with Codex or Claude Code and refresh.") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(28) + .frame(maxWidth: .infinity) .background(LitheTheme.settingsControlBackground) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(LitheTheme.settingsControlBorder, style: StrokeStyle(lineWidth: 1, dash: [4]))) } private func backupLine(_ url: URL) -> some View { @@ -157,13 +176,15 @@ struct McpServerLibraryView: View { } /// One library row: identity, a one-line command summary and per-Agent chips. -private struct McpServerRowView: View { +struct McpServerRowView: View { @ObservedObject var feature: McpServerLibraryFeatureModel let row: McpServerRow let edit: () -> Void + @State private var expanded = false var body: some View { - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { Button(action: edit) { HStack(spacing: 10) { icon @@ -187,10 +208,14 @@ private struct McpServerRowView: View { } if !row.isProjectOnly { - Image(systemName: "chevron.right") - .font(LitheTheme.uiFont(size: 9, weight: .semibold)) - .foregroundStyle(LitheTheme.tertiaryText) + Button { expanded.toggle() } label: { + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(LitheTheme.uiFont(size: 10, weight: .semibold)) + }.buttonStyle(.plain).foregroundStyle(LitheTheme.secondaryText) + .accessibilityLabel(Text("Server tools")) } + } + if expanded { McpToolsPanel(feature: feature.tools, row: row) } } .padding(.horizontal, 10) .padding(.vertical, 8) @@ -204,14 +229,8 @@ private struct McpServerRowView: View { } private var icon: some View { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(LitheTheme.accent.opacity(0.10)) - .frame(width: 26, height: 26) - .overlay { - Image(systemName: row.definition.transport == .stdio ? "terminal" : "network") - .font(LitheTheme.uiFont(size: 11, weight: .medium)) - .foregroundStyle(LitheTheme.accent) - } + McpBrandIcon(entry: feature.catalogEntry(for: row.definition) ?? McpRegistryEntry( + id: row.id, title: row.name, summary: "", version: "", website: nil, draft: row.definition)) } private var identity: some View { @@ -220,6 +239,7 @@ private struct McpServerRowView: View { Text(row.name) .font(LitheTheme.uiFont(size: 12.5, weight: .medium)) .foregroundStyle(LitheTheme.primaryText) + McpToolStatusBadge(feature: feature.tools, row: row) if row.isLitheRegistration { badge("Lithe", color: LitheTheme.accent) } else if row.isProjectOnly { @@ -270,6 +290,7 @@ private struct McpAgentChip: View { var body: some View { Button(action: action) { HStack(spacing: 3) { + AgentBrandIcon(name: agent.displayName, size: 13, style: .brand) Image(systemName: isOn ? "checkmark" : "plus") .font(LitheTheme.uiFont(size: 8.5, weight: .bold)) Text(agent.displayName) @@ -298,12 +319,14 @@ private struct McpAgentChip: View { } /// Detail editor for one server definition. -private struct McpServerEditorView: View { +struct McpServerEditorView: View { @ObservedObject var feature: McpServerLibraryFeatureModel @Binding var draft: McpServerDraft let onClose: () -> Void @State private var didAttemptSave = false + @State private var commandLine = "" + @State private var commandError: String? private var original: McpServerRow? { guard let id = draft.originalRowID else { return nil } @@ -311,107 +334,98 @@ private struct McpServerEditorView: View { } private var validationMessage: String? { - feature.validationMessage(for: draft) + (draft.transport == .stdio ? commandError : nil) ?? feature.validationMessage(for: draft) } var body: some View { - VStack(alignment: .leading, spacing: 10) { - backBar - serverSection - if draft.transport == .stdio { - environmentSection - } else { - headersSection - } - agentsSection - destinationSection - - if didAttemptSave, let message = validationMessage { - LitheBanner(status: .warning) { - Text(message).font(LitheTheme.smallFont) - } - } - if let error = feature.error { - LitheBanner(status: .warning) { - Text(error) - .font(LitheTheme.smallFont) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: 12) { + heading + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if let message = draft.setupMessage { + Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + serverSection + if draft.transport == .stdio { + environmentSection + } else { + headersSection + } + agentsSection + if let message = validationMessage, didAttemptSave || commandError != nil { + LitheBanner(status: .warning) { Text(message).font(LitheTheme.smallFont) } + } + if let error = feature.error { + LitheBanner(status: .warning) { + Text(error).font(LitheTheme.smallFont).textSelection(.enabled) + } + } } + .padding(.vertical, 2) } - + Divider() footer } + .onAppear { + guard draft.transport == .stdio else { return } + do { commandLine = try feature.tools.commandLine(draft) } + catch { commandError = String(localized: "Could not read the command line.") } + } + .onChange(of: draft.transport) { value in + if value == .stdio { + do { commandLine = try feature.tools.commandLine(draft); commandError = nil } + catch { commandError = String(localized: "Could not read the command line.") } + } + } } - private var backBar: some View { + private var heading: some View { HStack(spacing: 8) { - Button(action: onClose) { - HStack(spacing: 3) { - Image(systemName: "chevron.left") - .font(LitheTheme.uiFont(size: 9, weight: .semibold)) - Text("All servers") - .font(LitheTheme.uiFont(size: 12, weight: .medium)) - } - } - .buttonStyle(.plain) - .foregroundStyle(LitheTheme.accent) - .lithePointer() - + McpBrandIcon(entry: feature.catalogEntry(for: definition) ?? McpRegistryEntry( + id: draft.name, title: draft.name, summary: "", version: "", website: nil, draft: definition)) + Text(draft.name.isEmpty ? String(localized: "New MCP server") : draft.name) + .font(LitheTheme.uiFont(size: 16, weight: .semibold)) Spacer(minLength: 8) - if original != nil { - Button(role: .destructive) { - guard let original, feature.delete(original) else { return } - onClose() - } label: { - Label("Delete", systemImage: "trash") - .font(LitheTheme.uiFont(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundStyle(LitheTheme.error) - .lithePointer() - } } } private var serverSection: some View { - LitheSettingsSection("Server") { - VStack(alignment: .leading, spacing: 9) { - McpSettingsRow("Name") { - TextField("Name", text: $draft.name) - .litheSettingsTextField() - .frame(width: 240) - } - McpSettingsRow("Transport") { - LitheSettingsSegmentedControl( - selection: $draft.transport, - options: McpTransport.allCases, - width: 210, - title: { $0.title } - ) + VStack(alignment: .leading, spacing: 12) { + McpSettingsRow("Name", info: "Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit.") { + TextField("Name", text: $draft.name) + .litheSettingsTextField() + } + McpSettingsRow("Transport") { + LitheSettingsSegmentedControl( + selection: $draft.transport, + options: McpTransport.allCases, + width: 210, + title: { $0.title } + ) + } + if draft.transport == .stdio { + McpSettingsRow("Command", info: "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated.") { + TextField("npx -y @playwright/mcp", text: $commandLine, axis: .vertical) + .font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + .lineLimit(1...3).litheSettingsTextField() + .onChange(of: commandLine) { line in + do { try feature.tools.updateCommandLine(line, draft: &draft); commandError = nil } + catch { commandError = String(localized: "Check command-line quotes before saving.") } + } } - if draft.transport == .stdio { - McpSettingsRow("Command") { - TextField("Command", text: $draft.command) - .litheSettingsTextField() - } - McpSettingsRow("Arguments", info: "One argument per line. Arguments keep spaces without quoting.") { - TextEditor(text: $draft.argumentsText) - .font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) - .litheSettingsTextEditor(height: 64) - } - } else { - McpSettingsRow("URL") { - TextField("https://example.com/mcp", text: $draft.url) - .litheSettingsTextField() - } + } else { + McpSettingsRow("URL") { + TextField("https://example.com/mcp", text: $draft.url) + .litheSettingsTextField() } } } } private var environmentSection: some View { - LitheSettingsSection("Environment variables") { + McpSettingsRow("Environment variables", info: "Values are masked here but stored as plain text in the Agent's configuration file.") { McpKeyValueEditor( entries: $draft.env, keyPlaceholder: "Name", @@ -422,7 +436,7 @@ private struct McpServerEditorView: View { } private var headersSection: some View { - LitheSettingsSection("Headers") { + McpSettingsRow("Headers", info: "Values are masked here but stored as plain text in the Agent's configuration file.") { McpKeyValueEditor( entries: $draft.headers, keyPlaceholder: "Header", @@ -433,31 +447,25 @@ private struct McpServerEditorView: View { } private var agentsSection: some View { - LitheSettingsSection( - title: "Available to Agents", - accessory: { - LitheInfoTip("Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first.") - }, - content: { - VStack(alignment: .leading, spacing: 7) { - ForEach(McpAgent.allCases) { agent in - agentRow(agent) - } + McpSettingsRow("Available to Agents", info: "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first.") { + HStack(alignment: .top, spacing: 16) { + Button("All") { draft.enabledAgents = Set(McpAgent.allCases) } + .buttonStyle(LitheActionButtonStyle()) + ForEach(McpAgent.allCases) { agent in + agentRow(agent) } } - ) + } } private func agentRow(_ agent: McpAgent) -> some View { let isOn = binding(for: agent) let owned = original?.ownedGlobalAgents.contains(agent) == true return VStack(alignment: .leading, spacing: 2) { - LitheSettingsCheckbox(isOn: isOn, title: LocalizedStringKey(agent.displayName)) + McpAgentSelectionChip(agent: agent, selected: isOn.wrappedValue) { isOn.wrappedValue.toggle() } if isOn.wrappedValue, original != nil, !owned { HStack(spacing: 4) { - Text("Created outside Lithe.") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip("Created outside Lithe.") Button("Take ownership") { if let original { feature.takeOwnership(of: original, agent: agent) @@ -468,7 +476,6 @@ private struct McpServerEditorView: View { .foregroundStyle(LitheTheme.accent) .lithePointer() } - .padding(.leading, 20) } } } @@ -486,34 +493,17 @@ private struct McpServerEditorView: View { ) } - private var destinationSection: some View { - VStack(alignment: .leading, spacing: 3) { - Text("Saving writes to") - .font(LitheTheme.uiFont(size: 11, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) - let urls = feature.destinationURLs(for: draft) - if urls.isEmpty { - Text("No Agent is selected.") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - } else { - ForEach(urls, id: \.self) { url in - Text(url.path) - .font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - } - } - } - private var footer: some View { HStack(spacing: LitheTheme.ActionButton.spacing) { + if let original { + Button("Remove from library", role: .destructive) { + guard feature.delete(original) else { return }; onClose() + }.buttonStyle(.plain).foregroundStyle(LitheTheme.secondaryText) + } Spacer(minLength: 12) Button("Cancel", action: onClose) .buttonStyle(LitheActionButtonStyle()) + .keyboardShortcut(.cancelAction) Button("Save") { didAttemptSave = true guard feature.save(draft) else { return } @@ -524,6 +514,30 @@ private struct McpServerEditorView: View { } .padding(.top, 2) } + + private var definition: McpServerDefinition { + McpServerDefinition(name: draft.name, transport: draft.transport, command: draft.command, + args: draft.arguments, env: draft.env, url: draft.url, headers: draft.headers, definitionHash: "") + } +} + +struct McpAgentSelectionChip: View { + let agent: McpAgent + let selected: Bool + let action: () -> Void + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + AgentBrandIcon(name: agent.displayName, size: 16, style: .brand) + Text(agent.displayName).font(LitheTheme.settingsFont) + if selected { Image(systemName: "checkmark").font(LitheTheme.smallFont) } + } + .padding(.horizontal, 9).padding(.vertical, 6) + .background(selected ? LitheTheme.accent.opacity(0.10) : LitheTheme.settingsControlBackground) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(selected ? LitheTheme.accent.opacity(0.5) : LitheTheme.settingsControlBorder)) + }.buttonStyle(.plain).accessibilityLabel(Text(agent.displayName)).accessibilityValue(Text(selected ? "Selected" : "Not selected")) + } } /// Key/value rows for environment variables and HTTP headers. Values stay @@ -566,10 +580,6 @@ private struct McpKeyValueEditor: View { .buttonStyle(.plain) .foregroundStyle(LitheTheme.accent) .lithePointer() - - Text("Values are masked here but stored as plain text in the Agent's configuration file.") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) } } } @@ -589,7 +599,7 @@ private struct McpSettingsRow: View { var body: some View { HStack(alignment: .center, spacing: 12) { LitheSettingsLabel(title, info: info) - .frame(width: 110, alignment: .leading) + .frame(width: 155, alignment: .leading) content .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/macos/Sources/Lithe/Views/App/McpToolsPanel.swift b/macos/Sources/Lithe/Views/App/McpToolsPanel.swift new file mode 100644 index 0000000000..9698feec7c --- /dev/null +++ b/macos/Sources/Lithe/Views/App/McpToolsPanel.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct McpToolStatusBadge: View { + @ObservedObject var feature: McpToolsFeatureModel + let row: McpServerRow + var body: some View { + let state = feature.state(for: row) + if state.isReady || state.isLoading { + HStack(spacing: 4) { + Circle().fill(state.isReady ? LitheTheme.success : LitheTheme.secondaryText).frame(width: 5, height: 5) + Text(state.isLoading ? String(localized: "Detecting…") : String(format: String(localized: "%d tools"), state.tools.count)) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + } + } +} + +/// Expanding a row is read-only. Starting a local server always requires the +/// explicit Detect action and its execution notice; tool calls are never sent. +struct McpToolsPanel: View { + @ObservedObject var feature: McpToolsFeatureModel + let row: McpServerRow + @State private var agent: McpAgent = .codex + @State private var confirmsExecution = false + + private var state: McpToolsFeatureModel.State { feature.state(for: row) } + private var policy: McpToolPolicySnapshot? { state.policies[agent] } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Circle().fill(state.isReady ? LitheTheme.success : LitheTheme.secondaryText.opacity(0.5)).frame(width: 6, height: 6) + Text(state.isLoading ? String(localized: "Detecting…") : (state.isReady ? String(format: String(localized: "%d tools"), state.tools.count) : String(localized: "Not detected"))) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip("Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes.") + Spacer() + if state.isLoading { + Button("Cancel") { feature.cancelAll() }.buttonStyle(.plain) + } else { + Button(state.isReady ? "Detect again" : "Detect tools") { + if row.definition.transport == .stdio { confirmsExecution = true } + else { feature.detect(row) } + }.buttonStyle(LitheActionButtonStyle()) + } + } + if let error = state.error { + Text(error).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error).textSelection(.enabled) + if state.isReady { + Button("Reload tool settings") { feature.loadPolicies(row) }.buttonStyle(.plain).foregroundStyle(LitheTheme.accent) + } + } + if state.isReady && !state.tools.isEmpty { + HStack(spacing: 8) { + ForEach(McpAgent.allCases.filter { row.enabledGlobalAgents.contains($0) }) { value in + McpAgentSelectionChip(agent: value, selected: agent == value) { agent = value } + } + Spacer() + } + VStack(alignment: .leading, spacing: 0) { + ForEach(state.tools) { tool in + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: tool.name).font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + if !tool.description.isEmpty { + Text(verbatim: tool.description).font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText).lineLimit(4).help(tool.description) + } + } + Spacer(minLength: 8) + let permission = policy?.tools.first { $0.name == tool.name } + Toggle(isOn: Binding(get: { permission?.enabled == true }, set: { + feature.setEnabled($0, tool: tool.name, agent: agent, row: row) + })) { Text(verbatim: tool.name) } + .labelsHidden().toggleStyle(.switch).controlSize(.small) + .disabled(permission?.editable != true || state.isLoading) + .accessibilityLabel(Text("\(tool.name) in \(agent.displayName)")) + if permission?.editable == false { + LitheInfoTip("This tool is restricted outside Lithe. Edit the native Agent policy to enable it.") + } + } + .padding(.vertical, 9) + if tool.id != state.tools.last?.id { Divider() } + } + } + } else if state.isReady { + Text("This server reported no tools.").font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + } + } + .padding(12) + .background(LitheTheme.settingsSurface) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .onAppear { if !row.enabledGlobalAgents.contains(agent) { agent = row.enabledGlobalAgents.first ?? .codex } } + .alert("Run this MCP command?", isPresented: $confirmsExecution) { + Button("Cancel", role: .cancel) { } + Button("Run and detect") { feature.detect(row) } + } message: { + Text("Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process.") + } + } +} diff --git a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift index 210c8dc80b..62497d31d3 100644 --- a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift +++ b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift @@ -33,6 +33,10 @@ enum SettingsSearchVocabulary { ["AI & Commit", "Commit message", "Pull request"] case .agents: ["Agents", "Agent conversation", "Codex", "Claude", "ACP adapter", "Preflight", "Providers"] + case .instructions: + ["Instructions", "Global instructions", "AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "Codex", "Claude"] + case .skills: + ["Skills", "Agent skills", "SKILL.md", "Global skills", "Project skills", "Import skill", "New skill", "Codex", "Claude"] case .providers: ["AI Providers", "AI provider", "Model", "API key", "Endpoint", "Responses", "Anthropic"] case .git: diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 4b82a57ce6..8cd69dc96e 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -75,6 +75,7 @@ struct SettingsView: View { let initialCategory: SettingsCategory /// Changes with every category request; see `WorkbenchFeatureModel.settingsCategoryRequest`. let categoryRequest: Int + private let skillProjects: [URL] private let onDismiss: (() -> Void)? static let categoryMinimumWidth: CGFloat = 234 static let contentMinimumWidth: CGFloat = 500 @@ -85,12 +86,14 @@ struct SettingsView: View { viewState: SettingsViewState, initialCategory: SettingsCategory = .general, categoryRequest: Int = 0, + skillProjects: [URL] = [], onDismiss: (() -> Void)? = nil ) { self.settings = settings self.viewState = viewState self.initialCategory = initialCategory self.categoryRequest = categoryRequest + self.skillProjects = skillProjects self.onDismiss = onDismiss } @@ -467,9 +470,19 @@ struct SettingsView: View { case .project: EmptyView() case .run: EmptyView() case .ai: aiSettings + case .instructions: + AgentInstructionsSettingsView(feature: model.agentInstructionsFeature) case .agents: AgentPanelSettingsView(model: model, settings: settings, feature: model.agentManagementFeature) .id(model.settingsCategoryRequest) + case .skills: + AgentSkillsSettingsView(feature: model.agentSkillsFeature, workspace: model.workspaceURL, + openProjects: skillProjects, preferredAgentID: model.selectedAgentConversationID, + onOpen: { model.platformUI.open($0) }, onReveal: { model.platformUI.revealInFileBrowser($0) }, + onCopy: { model.platformUI.copyToClipboard($0) }, onChooseImport: { directory, title, prompt in + directory ? model.platformUI.chooseDirectory(title: title, prompt: prompt) + : model.platformUI.chooseFile(title: title, prompt: prompt) + }) case .providers: providersSettings case .git: GitSettingsPage(settings: settings) diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index dc55eb1923..89aa496339 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -514,11 +514,12 @@ final class LitheDropdownResizeHandle: ProjectReplaceCornerHandleView { @MainActor private final class LitheContextMenuPanel: NSPanel { + var retainsParentKey = false var handleKey: ((NSEvent) -> Bool)? var handleContentKey: ((NSEvent) -> Bool)? var handleMouseMoved: ((NSEvent) -> Void)? var handleResizeCursor: ((NSEvent) -> Void)? - override var canBecomeKey: Bool { true } + override var canBecomeKey: Bool { !retainsParentKey } override func sendEvent(_ event: NSEvent) { if event.type == .keyDown, handleKey?(event) == true { return } if event.type == .mouseMoved { handleMouseMoved?(event) } @@ -587,6 +588,8 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { private var panel: LitheContextMenuPanel? private var localEventMonitor: Any? private var globalEventMonitor: Any? + private var parentKeyObserver: NSObjectProtocol? + private var applicationObserver: NSObjectProtocol? private var visibleFrame: NSRect = .zero private weak var customContentController: NSViewController? private var contentDismissed: (() -> Void)? @@ -678,6 +681,11 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { .environment(\.locale, locale) let panel = makePanel(contentController: NSHostingController(rootView: content), appearance: appearance) + // Action menus need keyboard navigation, but no text editor. Keep the + // originating window key so its native switches retain their on tint. + // Searchable/custom dropdowns still take key focus in the other show path. + let owner = parentWindow ?? NSApp.currentEvent?.window ?? NSApp.keyWindow + panel.retainsParentKey = owner?.isKeyWindow == true panel.handleKey = { selection.handle($0) } panel.acceptsMouseMovedEvents = true panel.handleMouseMoved = { [weak panel] event in @@ -692,11 +700,22 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { self.panel = panel triggerView = trigger - parentWindow?.addChildWindow(panel, ordered: .above) + owner?.addChildWindow(panel, ordered: .above) contentDismissed = onDismiss installEventMonitors() panel.orderFrontRegardless() - panel.makeKey() + if panel.retainsParentKey, let owner { + parentKeyObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, + object: owner, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.dismiss() } + } + applicationObserver = NotificationCenter.default.addObserver(forName: NSApplication.didResignActiveNotification, + object: NSApp, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.dismiss() } + } + } else { + panel.makeKey() + } } private func makePanel(contentController: NSViewController, appearance: NSAppearance?) -> LitheContextMenuPanel { @@ -1061,8 +1080,19 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { matching: [.leftMouseDown, .rightMouseDown, .keyDown] ) { [weak self] event in guard let self else { return event } - if event.type == .keyDown, event.window === self.panel, self.panel?.handleKey?(event) == true { - return nil + if event.type == .keyDown, + self.panel?.childWindows?.contains(where: { ($0 as? LitheContextMenuPanel)?.retainsParentKey == true && $0.isVisible }) == true { + // A child action menu routes the key from this window itself. + return event + } + if event.type == .keyDown, + event.window === self.panel || (self.panel?.retainsParentKey == true && event.window === self.panel?.parent) { + if self.panel?.handleKey?(event) == true { return nil } + if self.panel?.retainsParentKey == true { + // Unhandled typing/shortcuts belong to the parent only after dismissal. + self.dismiss() + return event + } } var eventWindow = event.window while let window = eventWindow { @@ -1087,6 +1117,8 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { } private func removeEventMonitors() { + if let parentKeyObserver { NotificationCenter.default.removeObserver(parentKeyObserver); self.parentKeyObserver = nil } + if let applicationObserver { NotificationCenter.default.removeObserver(applicationObserver); self.applicationObserver = nil } if let localEventMonitor { NSEvent.removeMonitor(localEventMonitor) self.localEventMonitor = nil diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift b/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift new file mode 100644 index 0000000000..1e15531bed --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Agent/AgentInstructions.swift @@ -0,0 +1,66 @@ +import Foundation + +public enum AgentInstructionKind: String, CaseIterable, Identifiable, Sendable { + case codex = "codex-acp" + case claude = "claude-acp" + public var id: String { rawValue } + public var title: String { self == .codex ? "Codex" : "Claude Code" } +} + +/// Snapshot of the effective native user file. Revision/path guard a later save. +public struct AgentInstructionFile: Decodable, Equatable, Sendable { + public let agentId: String + public let path: String + public let content: String + public let exists: Bool + public let revision: String + + public init(agentId: String, path: String, content: String, exists: Bool, revision: String) { + self.agentId = agentId + self.path = path + self.content = content + self.exists = exists + self.revision = revision + } +} + +public protocol AgentInstructionsService: Sendable { + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile + func readShared() async throws -> SharedInstructionLibrary + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary +} + +public struct UnavailableAgentInstructionsService: AgentInstructionsService { + public init() {} + public func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { throw CocoaError(.featureUnsupported) } + public func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { throw CocoaError(.featureUnsupported) } +} + +/// Native snapshots and per-target failures keep partial synchronization explicit. +public struct SharedInstructionAgent: Decodable, Sendable { + public let agentId: String + public let file: AgentInstructionFile? + public let enabled: Bool? + public let needsSync: Bool + public let ownLineCount: Int + public let issue: String? + public init(agentId: String, file: AgentInstructionFile?, enabled: Bool?, needsSync: Bool, + ownLineCount: Int, issue: String?) { + self.agentId = agentId; self.file = file; self.enabled = enabled; self.needsSync = needsSync + self.ownLineCount = ownLineCount; self.issue = issue + } +} + +public struct SharedInstructionLibrary: Decodable, Sendable { + public let file: AgentInstructionFile + public let agents: [SharedInstructionAgent] + public init(file: AgentInstructionFile, agents: [SharedInstructionAgent]) { self.file = file; self.agents = agents } +} + +public extension AgentInstructionsService { + func readShared() async throws -> SharedInstructionLibrary { throw CocoaError(.featureUnsupported) } + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { throw CocoaError(.featureUnsupported) } + func setSharedEnabled(_ enabled: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { throw CocoaError(.featureUnsupported) } +} diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift b/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift index e57de9c358..e53fd5f464 100644 --- a/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift +++ b/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift @@ -157,6 +157,11 @@ public struct AgentCliUpdateResult: Decodable, Equatable, Sendable { /// Detects the user's runtime and installs ACP adapters with the user's npm. /// Node.js remains user-managed; CLI updates follow their verified installation owners. public protocol AgentManagementService: Sendable { + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] + func readSkill(at url: URL, agentID: String) async throws -> String + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing + func writeSkill(_ request: AgentSkillWrite) async throws -> URL + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing func status(dataDirectory: URL) async throws -> AgentManagementStatus /// Installs the pinned adapter version; returns the installed version. func install(agentID: String, dataDirectory: URL) async throws -> String @@ -171,6 +176,16 @@ public protocol AgentManagementService: Sendable { } public extension AgentManagementService { + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing { throw CocoaError(.featureUnsupported) } + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] { [:] } + func readSkill(at url: URL, agentID: String) async throws -> String { throw CocoaError(.featureUnsupported) } + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing { + throw CocoaError(.featureUnsupported) + } + func writeSkill(_ request: AgentSkillWrite) async throws -> URL { + throw CocoaError(.featureUnsupported) + } + func install(agentID: String, dataDirectory: URL, onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> String { try await install(agentID: agentID, dataDirectory: dataDirectory) diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift b/macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift new file mode 100644 index 0000000000..7743c815de --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Native discovery metadata. Claude enablement is scoped configured visibility, +/// not a claim about managed policy or an already connected session. +public struct AgentSkill: Decodable, Equatable, Identifiable, Sendable { + public var id: String { path } + public let name: String + public let description: String + public let path: String + public let scope: String + public let enabled: Bool? + public let pluginId: String? + + public init(name: String, description: String, path: String, scope: String, enabled: Bool? = nil, pluginId: String? = nil) { + self.name = name; self.description = description; self.path = path; self.scope = scope; self.enabled = enabled; self.pluginId = pluginId + } +} + +/// A deliberate write to native settings, addressed by discovered skill path. +public struct AgentSkillEnablement: Encodable, Sendable { + public let action = "setEnabled" + public let agentId: String + public let workspace: String? + public let scope: String + public let source: String + public let enabled: Bool + + public init(agentID: String, workspace: URL?, skill: AgentSkill, enabled: Bool) { + agentId = agentID; self.workspace = workspace?.path; scope = skill.scope; source = skill.path; self.enabled = enabled + } +} + +public struct AgentSkillListing: Decodable, Equatable, Sendable { + public let skills: [AgentSkill] + /// "agent" for native discovery, "directory" for a partial file inventory. + public let source: String + public let issues: [String] + public let directories: [String: String] + + public init(skills: [AgentSkill], source: String, issues: [String] = [], directories: [String: String] = [:]) { + self.skills = skills; self.source = source; self.issues = issues; self.directories = directories + } +} + +/// Explicit write to the selected Agent's native user/project skill directory. +public struct AgentSkillWrite: Encodable, Sendable { + public let action: String + public let agentId: String + public let workspace: String? + public let scope: String + public let name: String? + public let description: String? + public let instructions: String? + public let source: String? + + public init(agentID: String, workspace: URL?, scope: String, name: String, description: String, instructions: String) { + action = "create"; agentId = agentID; self.workspace = workspace?.path; self.scope = scope + self.name = name; self.description = description; self.instructions = instructions; source = nil + } + + public init(agentID: String, workspace: URL?, scope: String, source: URL) { + action = "import"; agentId = agentID; self.workspace = workspace?.path; self.scope = scope + name = nil; description = nil; instructions = nil; self.source = source.path + } +} diff --git a/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift new file mode 100644 index 0000000000..bebe4133af --- /dev/null +++ b/macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift @@ -0,0 +1,115 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("Global instruction settings appearance") +@MainActor +struct AgentInstructionsAppearanceTests { + @Test(arguments: [false, true]) + func nativeEditorFitsSettingsAndPreservesUnicode(_ dark: Bool) async throws { + let feature = AgentInstructionsFeatureModel(service: AppearanceInstructions()) + await feature.load() + feature.toggleEditor(.codex) + for language in ["en", "zh-Hans"] { + let resources = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Resources") + let bundle = try #require(Bundle(url: resources.appendingPathComponent("\(language).lproj"))) + #expect(AgentLocalization.string("Instructions", locale: Locale(identifier: language), bundle: bundle) + == (language == "en" ? "Instructions" : "指令")) + for width in [CGFloat(500), CGFloat(760)] { + let root = SettingsAppearanceContainer(themePreference: dark ? .dark : .light) { + ScrollView { AgentInstructionsSettingsView(localizationBundle: bundle, feature: feature).padding(16) } + }.environment(\.locale, Locale(identifier: language)) + let host = NSHostingView(rootView: root) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 660), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { window.contentView = nil; window.close() } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + var editors: [NSTextView] = [] + while clock.now < deadline { + host.layoutSubtreeIfNeeded() + editors = textViews(host).filter { $0.string == AppearanceInstructions.text } + if !editors.isEmpty && feature.sharedLibrary != nil && !feature.isSharedBusy { break } + await Task.yield() + } + let editor = try #require(editors.first, "the shared editor must host native UTF-8 text") + let rect = editor.convert(editor.bounds, to: host) + #expect(rect.minX >= 0 && rect.maxX <= width + 1) + #expect(editor.isEditable) + #expect(feature.file?.path == "/fixture/user/.codex/AGENTS.md") + let folder = ProcessInfo.processInfo.environment["LITHE_INSTRUCTIONS_SCREENSHOTS"] + .map { URL(fileURLWithPath: $0) } + ?? DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/instructions-ui") + do { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + let path = folder.appendingPathComponent("instructions-\(dark ? "dark" : "light")-\(language)-\(Int(width)).png") + try png.write(to: path) + } + // Exercise native typing through SwiftUI's binding, not just model.edit. + window.makeFirstResponder(editor) + editor.insertText("\n原生输入", replacementRange: NSRange(location: editor.string.utf16.count, length: 0)) + let typingDeadline = clock.now.advanced(by: .seconds(2)) + while !feature.draft.hasSuffix("原生输入") && clock.now < typingDeadline { await Task.yield() } + #expect(feature.draft == AppearanceInstructions.text + "\n原生输入") + #expect(feature.canSave) + #expect(feature.file?.content == AppearanceInstructions.text, "typing must not save automatically") + feature.edit(AppearanceInstructions.text) + } + } + } + + private func textViews(_ view: NSView) -> [NSTextView] { + if let editor = view as? NSTextView { return [editor] } + return view.subviews.flatMap(textViews) + } +} + +private struct AppearanceInstructions: AgentInstructionsService { + static let text = "# 全局指令\n\n使用简体中文回复。\n保留代码标识符和命令的原始拼写。\n" + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { + AgentInstructionFile(agentId: agent.rawValue, path: "/fixture/user/.codex/AGENTS.md", + content: Self.text, exists: true, revision: "fixture") + } + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { file } + func readShared() async throws -> SharedInstructionLibrary { + SharedInstructionLibrary(file: .init(agentId: "shared", path: "/fixture/library/Shared.md", content: "", exists: false, revision: "shared"), + agents: [SharedInstructionAgent(agentId: "codex-acp", file: try await read(agent: .codex), enabled: false, + needsSync: false, ownLineCount: 4, issue: nil), + SharedInstructionAgent(agentId: "claude-acp", file: try await read(agent: .claude), enabled: false, + needsSync: false, ownLineCount: 4, issue: nil)]) + } +} + +@Suite("Global instruction contract") +struct AgentInstructionsContractTests { + @Test func sharedLibraryDecodesActualUnknownAndEnabledTargets() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + struct Fixture: Decodable { let library: SharedInstructionLibrary } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/shared-instructions-v1.json"))) + #expect(fixture.library.agents[0].enabled == true) + #expect(fixture.library.agents[1].enabled == nil) + #expect(fixture.library.agents[1].issue != nil) + #expect(fixture.library.file.agentId == "shared") + } + + @Test func sharedSnapshotsDecodeOnBothPlatforms() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + struct Fixture: Decodable { let file: AgentInstructionFile; let windowsFile: AgentInstructionFile } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/agent-instructions-v1.json"))) + #expect(fixture.file.agentId == "codex-acp") + #expect(fixture.file.revision.count == 64) + #expect(fixture.windowsFile.agentId == "claude-acp") + #expect(fixture.windowsFile.path == "C:\\Users\\fixture\\.claude\\CLAUDE.md") + } +} diff --git a/macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift b/macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift new file mode 100644 index 0000000000..f430821482 --- /dev/null +++ b/macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift @@ -0,0 +1,79 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct AgentInstructionsCancellationTests { + @Test func cancelledBeforeServiceEntryDoesNotSaveSharedInstructions() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let path = root.appendingPathComponent("Shared.md") + try Data("original shared instructions".utf8).write(to: path) + let service = RustAgentInstructionsService(core: RustCoreBridge(), sharedDirectory: root) + let library = try await service.readShared() + // No Agent targets: a non-cancelled request would write only our real + // isolated source file, never the developer's native instruction files. + let sourceOnly = SharedInstructionLibrary(file: library.file, agents: []) + let task = Task { try await service.saveShared(content: "cancelled edit", library: sourceOnly) } + task.cancel() // MainActor does not yield until cancellation is recorded. + do { + _ = try await task.value + Issue.record("A cancelled save must not publish a result") + } catch { #expect(error is CancellationError) } + #expect(try String(contentsOf: path, encoding: .utf8) == library.file.content) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path) == ["Shared.md"]) + } + + @Test func cancellationDuringEncodingIsReplayedBeforeCoreDispatch() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let service = RustAgentInstructionsService(core: RustCoreBridge(), sharedDirectory: root) + let library = try await service.readShared() + let core = RustCoreBridge() + let operationID = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operationID) + // Encoding runs after the worker's entry check but before Core registers + // the operation. The real registration callback must replay this cancel. + let payload = CancelDuringEncoding(value: SharedSave(dataDirectory: root.path, + content: "must not be written", expectedRevision: library.file.revision), cancellation: cancellation) + let result: Result = core.executeResult( + command: "agent.instructions.shared", payload: payload, operationID: operationID, + onEvent: { cancellation.receive($0) }) + switch result { + case .success: Issue.record("Cancellation before registration must prevent dispatch") + case .failure(let error): #expect(error.code == "cancelled") + } + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + #expect(!core.cancel(operationID: operationID)) // Registration was released. + + // Prove the same valid snapshot can save, and an old cancellation does + // not poison the next unique operation or any native Agent files. + _ = try await service.saveShared(content: "allowed edit", library: SharedInstructionLibrary(file: library.file, agents: [])) + #expect(try String(contentsOf: root.appendingPathComponent("Shared.md"), encoding: .utf8) == "allowed edit") + } + + private func remove(_ root: URL) { + do { try FileManager.default.removeItem(at: root) } + catch { Issue.record("Instruction fixture cleanup failed: \(error)") } + } +} + +private struct SharedSave: Encodable { + let dataDirectory: String + let action = "save" + let content: String + let expectedRevision: String + let files: [String] = [] +} + +private struct CancelDuringEncoding: Encodable { + let value: SharedSave + let cancellation: RustCoreCancellation + func encode(to encoder: any Encoder) throws { + try value.encode(to: encoder) + cancellation.cancel() + } +} diff --git a/macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift b/macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift new file mode 100644 index 0000000000..0ef0d8afd6 --- /dev/null +++ b/macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("Global Agent instructions") +@MainActor +struct AgentInstructionsFeatureModelTests { + @Test func draftsAreIndependentAndSurviveNavigation() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("Codex draft 中文") + feature.selectedAgent = .claude + await feature.load() + feature.edit("Claude draft") + feature.selectedAgent = .codex + await feature.load() + #expect(feature.draft == "Codex draft 中文") + #expect(feature.isDirty && feature.canSave) + #expect(await service.reads == [.codex, .claude]) + } + + @Test func successfulSaveConfirmsNativeResultAndDoesNotOverwriteAnotherAgentDraft() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("new global instructions") + await service.onSave { + await MainActor.run { + #expect(feature.phase == .saving) + #expect(!feature.canSave) + } + // A duplicate save and reload must not race the pending write. + await feature.save() + await feature.load(reload: true) + await MainActor.run { feature.selectedAgent = .claude } + await feature.load() + await MainActor.run { feature.edit("other draft") } + } + await feature.save() + #expect(feature.selectedAgent == .claude) + #expect(feature.draft == "other draft") + #expect(feature.files[.codex]?.content == "new global instructions") + #expect(feature.saved.contains(.codex)) + #expect(await service.writes == 1) + feature.selectedAgent = .codex + #expect(!feature.isDirty) + } + + @Test func failedSaveKeepsDraftAndExplicitReloadUsesExternalContents() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("keep this draft") + await service.setFailure(.conflict) + await feature.save() + #expect(feature.phase == .failed("External file changed")) + #expect(feature.draft == "keep this draft") + #expect(feature.file?.content == "Codex original") + #expect(feature.saved.isEmpty) + await service.setFailure(nil) + await service.replaceCodex("External text") + await feature.load(reload: true) + #expect(feature.draft == "External text") + #expect(!feature.isDirty) + } + + @Test func failedReloadAndFailedInitialLoadAreRetryable() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await service.setFailure(.read) + await feature.load() + #expect(feature.file == nil) + #expect(!feature.canSave) + #expect(feature.phase == .failed("Cannot read")) + await service.setFailure(nil) + await feature.load() + feature.edit("draft") + await service.setFailure(.read) + await feature.load(reload: true) + #expect(feature.draft == "draft") + #expect(feature.file?.content == "Codex original") + } + + @Test func emptyTextIsSavedAndOversizedTextIsRejectedBeforeNativeWrite() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit(String(repeating: "文", count: 350_000)) + #expect(!feature.canSave, "the limit counts UTF-8 bytes") + await feature.save() + #expect(await service.writes == 0) + feature.edit("") + await feature.save() + #expect(feature.file?.content == "") + #expect(feature.saved.contains(.codex)) + } + + @Test func nativeReadbackCanSwitchBackFromAnEmptyOverride() async { + let service = InstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.load() + feature.edit("") + await service.returnEffectiveBaseAfterSave() + await feature.save() + #expect(feature.file?.path == "/fixture/codex/AGENTS.md") + #expect(feature.draft == "Effective base") + #expect(!feature.isDirty) + } +} + +private actor InstructionsStub: AgentInstructionsService { + enum Failure: LocalizedError { + case read, conflict + var errorDescription: String? { self == .read ? "Cannot read" : "External file changed" } + } + var reads: [AgentInstructionKind] = [] + var writes = 0 + private var failure: Failure? + private var saveCallback: (@Sendable () async -> Void)? + private var effectiveBase = false + private var codexContent = "Codex original" + + func onSave(_ callback: @escaping @Sendable () async -> Void) { saveCallback = callback } + func setFailure(_ value: Failure?) { failure = value } + func replaceCodex(_ content: String) { codexContent = content } + func returnEffectiveBaseAfterSave() { effectiveBase = true } + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { + reads.append(agent) + if failure == .read { throw Failure.read } + return AgentInstructionFile(agentId: agent.rawValue, path: "/fixture/\(agent.id)/instructions.md", + content: agent == .codex ? codexContent : "Claude original", exists: true, revision: "snapshot") + } + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { + writes += 1 + let callback = saveCallback + saveCallback = nil + await callback?() + if let failure { throw failure } + return AgentInstructionFile(agentId: file.agentId, + path: effectiveBase ? "/fixture/codex/AGENTS.md" : file.path, + content: effectiveBase ? "Effective base" : content, exists: true, revision: "saved") + } +} diff --git a/macos/Tests/LitheTests/AgentSkillsCancellationTests.swift b/macos/Tests/LitheTests/AgentSkillsCancellationTests.swift new file mode 100644 index 0000000000..56a2842322 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSkillsCancellationTests.swift @@ -0,0 +1,94 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct AgentSkillsCancellationTests { + @Test(arguments: ["create", "import", "disable"]) + func cancelledBeforeServiceEntryLeavesSkillAndNativeConfigurationUnchanged(action: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let service = RustAgentManagementService(core: RustCoreBridge()) + let existing = AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "existing", + description: "Cancellation fixture", instructions: "Read local files.") + let source = try await service.writeSkill(existing) + let settings = root.appendingPathComponent(".claude/settings.local.json") + try Data(#"{"keep":"original","skillOverrides":{"existing":"on"}}"#.utf8).write(to: settings) + let imported = root.appendingPathComponent("imported", isDirectory: true) + try FileManager.default.createDirectory(at: imported, withIntermediateDirectories: true) + try Data("---\nname: imported\ndescription: Cancellation fixture\n---\nRead local files.\n".utf8) + .write(to: imported.appendingPathComponent("SKILL.md")) + let before = try snapshot(root) + let request = action == "import" + ? AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", source: imported) + : AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "cancelled", + description: "Must not be created", instructions: "Do not publish.") + let skill = AgentSkill(name: "existing", description: "Cancellation fixture", path: source.path, scope: "repo", enabled: true) + let task = Task { + if action == "disable" { + _ = try await service.setSkillEnabled(.init(agentID: "claude-acp", workspace: root, skill: skill, enabled: false)) + } else { + _ = try await service.writeSkill(request) + } + } + // Both caller and task are MainActor-isolated; cancellation precedes + // the service entry, without a sleep or dependence on worker speed. + task.cancel() + do { + try await task.value + Issue.record("Cancelled skill mutation must not succeed") + } catch { #expect(error is CancellationError) } + #expect(try snapshot(root) == before) + } + + @Test func cancellationDuringEncodingIsReplayedBeforeSkillPublication() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { remove(root) } + let core = RustCoreBridge() + let operationID = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operationID) + let request = AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "registered", + description: "Valid fixture", instructions: "Only publish when allowed.") + let result: Result = core.executeResult( + command: "agent.skills", payload: CancelDuringSkillEncoding(value: request, cancellation: cancellation), + operationID: operationID, onEvent: { cancellation.receive($0) }) + switch result { + case .success: Issue.record("Pre-registration cancellation must prevent skill dispatch") + case .failure(let error): #expect(error.code == "cancelled") + } + #expect(try snapshot(root).isEmpty) + #expect(!core.cancel(operationID: operationID)) + // The request is valid and the cancelled registration does not affect a + // subsequent operation. All paths stay in this isolated Claude project. + let path = try await RustAgentManagementService(core: core).writeSkill(request) + #expect(FileManager.default.fileExists(atPath: path.path)) + } + + private func snapshot(_ root: URL) throws -> [String: Data] { + let urls = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isDirectoryKey])) + var result: [String: Data] = [:] + for case let url as URL in urls { + let directory = try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true + result[String(url.path.dropFirst(root.path.count))] = directory ? Data() : try Data(contentsOf: url) + } + return result + } + + private func remove(_ root: URL) { + do { try FileManager.default.removeItem(at: root) } + catch { Issue.record("Skill cancellation fixture cleanup: \(error)") } + } +} + +private struct SkillPath: Decodable, Sendable { let path: String } +private struct CancelDuringSkillEncoding: Encodable { + let value: AgentSkillWrite + let cancellation: RustCoreCancellation + func encode(to encoder: any Encoder) throws { + try value.encode(to: encoder) + cancellation.cancel() + } +} diff --git a/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift new file mode 100644 index 0000000000..bf9e5cbefb --- /dev/null +++ b/macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift @@ -0,0 +1,616 @@ +import AppKit +import SwiftUI +import Combine +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@MainActor +struct AgentSkillsFeatureModelTests { + @Test nonisolated func sharedFixtureDecodesNativeAndConfiguredVisibility() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/skills-management-v1.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let decode: (String) throws -> AgentSkillListing = { key in + try JSONDecoder().decode(AgentSkillListing.self, from: JSONSerialization.data(withJSONObject: fixture[key] as Any)) + } + let native = try decode("normalizedCodex"), inventory = try decode("claudeInventory") + #expect(native.skills.map(\.scope) == ["user", "repo", "user"]) + #expect(native.skills[1].enabled == false) + #expect(native.skills.first(where: { $0.pluginId != nil })?.pluginId == "helper@example") + #expect(inventory.skills.first?.enabled == false) + #expect(inventory.source == "directory") + } + + @Test func scopeIsPrimaryAndSourceFilteringPreservesNativeProvenance() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + await feature.refresh(.init(workspace: service.firstProject)) + #expect(feature.phase == .ready) + #expect(feature.count(in: "user") == 2) + #expect(feature.count(in: "repo") == 2) + #expect(feature.visibleItems.map(\.agentName) == ["Claude", "Codex"]) + feature.scope = "repo" + #expect(feature.visibleItems.allSatisfy { $0.skill.scope == "repo" }) + feature.agentFilter = "claude-acp" + #expect(feature.visibleItems.count == 1) + #expect(feature.visibleItems.first?.skill.enabled == true) + feature.query = "no-match" + #expect(feature.visibleItems.isEmpty) + feature.query = "PROJECT" + #expect(feature.visibleItems.count == 1) + feature.cancel() + } + + @Test func noProjectNeverReclassifiesTheHomeDirectoryAsAProject() async { + let feature = AgentSkillsFeatureModel(service: SkillsTestService()) + await feature.refresh(.init(workspace: nil)) + #expect(feature.count(in: "user") == 2) + #expect(feature.count(in: "repo") == 0) + feature.scope = "repo" + #expect(feature.visibleItems.isEmpty) + feature.cancel() + } + + @Test func independentFailuresKeepPreviousRowsAndDoNotHideTheOtherAgent() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + let previous = feature.listings["codex-acp"] + service.failCodex = true + await feature.refresh(context) + #expect(feature.phase == .ready) + #expect(feature.listings["codex-acp"] == previous) + #expect(feature.discoveryErrors["codex-acp"] != nil) + #expect(feature.discoveryErrors["claude-acp"] == nil) + #expect(feature.directory(agentID: "codex-acp", scope: "repo") != nil) + feature.cancel() + } + + @Test func oldProjectResultsCannotReplaceTheNewProjectsSkills() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + service.holdFirstProject = true + await withTaskGroup(of: Void.self) { group in + group.addTask { await feature.refresh(.init(workspace: service.firstProject)) } + #expect(await awaitChange(on: service) { service.held.count == 2 }, "both old requests reached the controlled gate") + await feature.refresh(.init(workspace: service.secondProject)) + let selected = feature.listings + service.releaseLists() + await group.waitForAll() + #expect(feature.listings == selected) + #expect(feature.visibleItems.allSatisfy { $0.skill.path.contains("second") }) + } + feature.cancel() + } + + @Test func writeRunsOnceWithCapturedScopeAndRefreshesOnlyAfterSuccess() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + service.holdWrite = true + let request = AgentSkillWrite(agentID: "claude-acp", workspace: context.workspace, scope: "repo", + name: "new-skill", description: "Use for testing", instructions: "Inspect the files") + feature.write(request, context: context) + feature.write(request, context: context) + #expect(await awaitChange(on: service) { service.writeRequests.count == 1 }) + #expect(feature.isWriting) + let payload = try? JSONSerialization.jsonObject(with: JSONEncoder().encode(service.writeRequests[0])) as? [String: Any] + #expect(payload?["workspace"] as? String == service.firstProject.path) + #expect(payload?["scope"] as? String == "repo") + service.releaseWrite() + #expect(await awaitChange(on: feature) { !feature.isWriting && feature.savedURL != nil && feature.phase == .ready }) + #expect(service.listRequests == 4) + #expect(service.writeRequests.count == 1) + feature.cancel() + } + + @Test func leavingThePageRejectsLateWriteFeedback() async { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + service.holdWrite = true + feature.write(.init(agentID: "codex-acp", workspace: context.workspace, scope: "repo", name: "new-skill", + description: "Testing", instructions: "Inspect"), context: context) + #expect(await awaitChange(on: service) { service.writeRequests.count == 1 }) + feature.cancel() + service.releaseWrite() + #expect(await awaitChange(on: service) { service.finishedWrites == 1 }) + #expect(feature.savedURL == nil) + #expect(!feature.isWriting) + } + + @Test func toggleImmediatelyShowsTheChoiceAndSerializesNativeConfirmation() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + service.holdWrite = true + feature.setEnabled(false, for: item, context: context) + #expect(feature.displayedEnablement(for: item) == false) + #expect(!feature.didChangeEnablement, "the immediate display is not a native confirmation") + feature.setEnabled(false, for: item, context: context) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + #expect(feature.isWriting && feature.pendingEnablements.first?.itemID == item.id) + #expect(feature.visibleItems.first?.skill.enabled == true, "pending writes do not invent native state") + await feature.refresh(context) + #expect(feature.isWriting, "manual refresh cannot invalidate the write") + let request = try #require(service.toggleRequests.first) + #expect(request.workspace == context.workspace?.path && request.source == item.skill.path && !request.enabled) + service.releaseWrite() + #expect(await awaitChange(on: feature) { feature.didChangeEnablement && !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == false) + #expect(feature.pendingEnablements.isEmpty) + #expect(service.toggleRequests.count == 1) + } + + @Test func failedToggleRestoresConfirmedStateAndUnknownStateCannotBeChanged() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + service.failToggle = true + service.holdWrite = true + feature.setEnabled(false, for: item, context: context) + #expect(feature.displayedEnablement(for: item) == false) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + service.releaseWrite() + #expect(await awaitChange(on: feature) { feature.error != nil && !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == true) + #expect(feature.displayedEnablement(for: item) == true && feature.pendingEnablements.isEmpty) + #expect(!feature.didChangeEnablement) + service.unknownEnablement = true + await feature.refresh(context) + let unknown = try #require(feature.visibleItems.first) + feature.setEnabled(true, for: unknown, context: context) + #expect(!feature.isWriting && service.toggleRequests.count == 1) + } + + @Test func otherRowsQueueTheirOwnChoiceWithoutOverwritingEarlierConfirmation() async throws { + let service = SkillsTestService() + service.additionalCodexSkill = true + let feature = AgentSkillsFeatureModel(service: service) + feature.agentFilter = "codex-acp" + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let first = try #require(feature.visibleItems.first), second = try #require(feature.visibleItems.last) + service.holdWrite = true + feature.setEnabled(false, for: first, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + #expect(!feature.canChangeEnablement(for: first) && feature.canChangeEnablement(for: second)) + #expect(feature.displayedEnablement(for: second) == true) + feature.setEnabled(false, for: second, context: context) + feature.setEnabled(false, for: second, context: context) + #expect(feature.pendingEnablements.count == 2 && service.toggleRequests.count == 1) + #expect(feature.displayedEnablement(for: second) == false) + + service.releaseWrite(holdingNext: true) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 2 }) + #expect(feature.visibleItems.first?.skill.enabled == false) + #expect(feature.visibleItems.last?.skill.enabled == true, "the queued row still awaits native confirmation") + #expect(feature.canChangeEnablement(for: first) && !feature.canChangeEnablement(for: second)) + #expect(feature.isWriting && !feature.didChangeEnablement) + let confirmedFirst = try #require(feature.visibleItems.first) + feature.setEnabled(true, for: confirmedFirst, context: context) + #expect(feature.displayedEnablement(for: confirmedFirst) == true) + service.releaseWrite(holdingNext: true) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 3 }) + #expect(feature.visibleItems.allSatisfy { $0.skill.enabled == false }) + service.releaseWrite() + try #require(await awaitChange(on: feature) { !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == true && feature.visibleItems.last?.skill.enabled == false) + #expect(feature.pendingEnablements.isEmpty && feature.didChangeEnablement) + #expect(service.toggleRequests.map(\.source) == [first.skill.path, second.skill.path, first.skill.path]) + } + + @Test func aFailedRowRollsBackWhileTheNextQueuedRowCanStillSave() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let first = try #require(feature.visibleItems.first), second = try #require(feature.visibleItems.last) + service.holdWrite = true; service.failToggle = true + feature.setEnabled(false, for: first, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + feature.setEnabled(false, for: second, context: context) + service.releaseWrite(holdingNext: true) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 2 }) + #expect(feature.error != nil && feature.visibleItems.first?.skill.enabled == true) + #expect(feature.displayedEnablement(for: first) == true && feature.displayedEnablement(for: second) == false) + service.failToggle = false + service.releaseWrite() + try #require(await awaitChange(on: feature) { !feature.isWriting }) + #expect(feature.visibleItems.first?.skill.enabled == true && feature.visibleItems.last?.skill.enabled == false) + #expect(feature.error != nil && !feature.didChangeEnablement, "a later success must not hide an earlier failure") + } + + @Test(arguments: [false, true]) + func leavingTheContextDropsQueuedWritesAndRejectsLateConfirmation(switchProject: Bool) async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + service.holdWrite = true + let first = try #require(feature.visibleItems.first), second = try #require(feature.visibleItems.last) + feature.setEnabled(false, for: first, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + feature.setEnabled(false, for: second, context: context) + if switchProject { await feature.refresh(.init(workspace: service.secondProject)) } + else { feature.cancel() } + let selected = feature.listings + service.releaseWrite() + try #require(await awaitChange(on: service) { service.finishedToggles == 1 }) + #expect(service.toggleRequests.count == 1, "a queued write cannot start after leaving its project or page") + #expect(feature.listings == selected && !feature.didChangeEnablement) + #expect(feature.pendingEnablements.isEmpty && !feature.isWriting) + } + + @Test func oldToggleCannotReplaceAnotherProjectsState() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + defer { service.releaseWrite(); feature.cancel() } + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + service.holdWrite = true + feature.setEnabled(false, for: item, context: context) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + await feature.refresh(.init(workspace: service.secondProject)) + let selected = feature.listings + service.releaseWrite() + #expect(await awaitChange(on: service) { service.finishedToggles == 1 }) + #expect(feature.listings == selected && !feature.didChangeEnablement) + #expect(feature.pendingEnablements.isEmpty && !feature.isWriting) + } +} + +@MainActor +private final class SkillsTestService: ObservableObject, AgentManagementService { + let firstProject = URL(fileURLWithPath: "/fixture/first") + let secondProject = URL(fileURLWithPath: "/fixture/second") + var failCodex = false + var additionalCodexSkill = false + var holdFirstProject = false + var holdWrite = false + @Published var held: [String: CheckedContinuation] = [:] + @Published var writeRequests: [AgentSkillWrite] = [] + @Published var finishedWrites = 0 + @Published var readRequests = 0 + @Published var toggleRequests: [AgentSkillEnablement] = [] + @Published var finishedToggles = 0 + var failToggle = false + var unknownEnablement = false + private var enablements: [String: Bool] = [:] + var listRequests = 0 + private var writeGate: CheckedContinuation? + + func listSkills(agentID: String, workspace: URL?) async throws -> AgentSkillListing { + listRequests += 1 + if holdFirstProject && workspace == firstProject { + await withCheckedContinuation { held[agentID] = $0 } + } + if failCodex && agentID == "codex-acp" { throw Failure() } + let root = workspace?.path ?? "/fixture/global" + var skills: [AgentSkill] = [ + .init(name: agentID + "-global", description: "Global test", path: root + "/user/" + agentID + "/SKILL.md", scope: "user", enabled: unknownEnablement ? nil : (enablements[root + "/user/" + agentID + "/SKILL.md"] ?? true)), + .init(name: agentID + "-project", description: "Project test", path: root + "/project/" + agentID + "/SKILL.md", scope: "repo", enabled: unknownEnablement ? nil : (enablements[root + "/project/" + agentID + "/SKILL.md"] ?? true)) + ] + if additionalCodexSkill && agentID == "codex-acp" { + let path = root + "/user/codex-secondary/SKILL.md" + skills.append(.init(name: "codex-secondary", description: "Another skill in the same native settings", + path: path, scope: "user", enabled: enablements[path] ?? true)) + } + return .init(skills: skills, source: agentID == "codex-acp" ? "agent" : "directory") + } + func skillDirectories(agentID: String, workspace: URL?) async throws -> [String: String] { + ["user": "/fixture/user/skills", "repo": (workspace?.path ?? "") + "/skills"] + } + func writeSkill(_ request: AgentSkillWrite) async throws -> URL { + writeRequests.append(request) + if holdWrite { await withCheckedContinuation { writeGate = $0 } } + finishedWrites += 1 + return URL(fileURLWithPath: "/fixture/saved/SKILL.md") + } + func setSkillEnabled(_ request: AgentSkillEnablement) async throws -> AgentSkillListing { + toggleRequests.append(request) + if holdWrite { await withCheckedContinuation { writeGate = $0 } } + defer { finishedToggles += 1 } + if failToggle { throw Failure() } + enablements[request.source] = request.enabled + return try await listSkills(agentID: request.agentId, workspace: request.workspace.map { URL(fileURLWithPath: $0) }) + } + func readSkill(at url: URL, agentID: String) async throws -> String { + readRequests += 1 + return "---\nname: example\ndescription: A skill for testing\n---\n\n# Workflow\n\n1. Inspect the project.\n2. Explain the result." + } + func releaseLists() { let gates = held.values; held = [:]; for gate in gates { gate.resume() } } + func releaseWrite(holdingNext: Bool = false) { + holdWrite = holdingNext + let gate = writeGate; writeGate = nil; gate?.resume() + } + func status(dataDirectory: URL) async throws -> AgentManagementStatus { throw Failure() } + func install(agentID: String, dataDirectory: URL) async throws -> String { throw Failure() } + func uninstall(agentID: String, dataDirectory: URL) async throws { throw Failure() } + func installCli(agentID: String, dataDirectory: URL) async throws -> AgentCliUpdateResult { throw Failure() } + private struct Failure: LocalizedError { var errorDescription: String? { "Controlled skill failure" } } +} + +struct AgentSkillsRustBridgeTests { + @Test func nativeCreateReadAndConflictUseTheRealCoreBridgeInAnIsolatedProject() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-skills-bridge-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = RustAgentManagementService(core: RustCoreBridge()) + let request = AgentSkillWrite(agentID: "claude-acp", workspace: root, scope: "repo", name: "bridge-skill", + description: "Verify the native bridge", instructions: "Read the project files.") + let result = try await service.writeSkill(request) + #expect(result.path.hasSuffix(".claude/skills/bridge-skill/SKILL.md")) + let content = try await service.readSkill(at: result, agentID: "claude-acp") + #expect(content.contains("Read the project files.")) + let directories = try await service.skillDirectories(agentID: "claude-acp", workspace: root) + #expect(directories["repo"]?.hasSuffix(".claude/skills") == true) + do { _ = try await service.writeSkill(request); Issue.record("Existing skill must not be overwritten") } + catch { #expect(!error.localizedDescription.isEmpty) } + #expect(try await service.readSkill(at: result, agentID: "claude-acp") == content) + let skill = AgentSkill(name: "bridge-skill", description: "Verify the native bridge", path: result.path, scope: "repo", enabled: true) + let toggled = try await service.setSkillEnabled(.init(agentID: "claude-acp", workspace: root, skill: skill, enabled: false)) + #expect(toggled.skills.first(where: { URL(fileURLWithPath: $0.path).resolvingSymlinksInPath() == result.resolvingSymlinksInPath() })?.enabled == false) + let nativeSettings = try Data(contentsOf: root.appendingPathComponent(".claude/settings.local.json")) + let json = try #require(JSONSerialization.jsonObject(with: nativeSettings) as? [String: Any]) + #expect((json["skillOverrides"] as? [String: String])?["bridge-skill"] == "off") + #expect(try await service.readSkill(at: result, agentID: "claude-acp") == content) + } +} + + +@MainActor +struct AgentSkillsAppearanceTests { + @Test(arguments: [false, true], [500, 760]) + func toggleFeedbackKeepsRowsInPlace(dark: Bool, width: Int) async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + let host = NSHostingView(rootView: SettingsAppearanceContainer(themePreference: dark ? .dark : .light) { + ScrollView { + AgentSkillsSettingsView(feature: feature, workspace: service.firstProject, + onOpen: { _ in }, onReveal: { _ in }, onCopy: { _ in }, onChooseImport: { _, _, _ in nil }) + .padding(16) + } + }.environment(\.locale, Locale(identifier: "zh-Hans"))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: CGFloat(width), height: 640), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.orderFront(nil) + defer { service.releaseWrite(); feature.cancel(); window.contentView = nil; window.close() } + layout(host) + try #require(await awaitChange(on: feature) { feature.phase == .ready && service.listRequests >= 4 }) + let initial = try await renderedSwitches(host, state: .on, enabled: true) + let originalFrames = initial.map { $0.convert($0.bounds, to: host) } + let name = "skills-stable-feedback-\(dark ? "dark" : "light")-\(width)" + try capture(host, name: name + "-initial") + + // Hold each native write so both the first save and the next click are observable. + for (index, enabled) in [false, true].enumerated() { + service.holdWrite = true + let item = try #require(feature.visibleItems.first) + feature.setEnabled(enabled, for: item, context: context) + try #require(await awaitChange(on: service) { service.toggleRequests.count == index + 1 }) + #expect(!feature.didChangeEnablement, "success is hidden until native confirmation") + let pending = try await renderedSwitches(host, state: enabled ? .on : .off, enabled: false) + #expect(pending.map { $0.convert($0.bounds, to: host) } == originalFrames, + "starting another save must not collapse the success message's space") + try capture(host, name: name + "-\(index)-pending") + service.releaseWrite() + try #require(await awaitChange(on: feature) { !feature.isWriting && feature.didChangeEnablement }) + let saved = try await renderedSwitches(host, state: enabled ? .on : .off, enabled: true) + #expect(saved.map { $0.convert($0.bounds, to: host) } == originalFrames, + "showing save confirmation must not shift any skill row") + try capture(host, name: name + "-\(index)-saved") + } + } + + @Test func openingAnActionMenuPreservesTheNativeSwitchStateAndAppearance() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + await feature.refresh(.init(workspace: service.firstProject)) + let host = NSHostingView(rootView: SettingsAppearanceContainer(themePreference: .dark) { + AgentSkillsSettingsView(feature: feature, workspace: service.firstProject, + onOpen: { _ in }, onReveal: { _ in }, onCopy: { _ in }, onChooseImport: { _, _, _ in nil }) + .padding(16) + }) + let window = SkillMenuFocusWindow(contentRect: NSRect(x: 100, y: 100, width: 760, height: 500), + styleMask: [.titled, .nonactivatingPanel], backing: .buffered, defer: false) + let presenter = LitheContextMenuPresenter() + window.isReleasedWhenClosed = false; window.contentView = host; window.makeKeyAndOrderFront(nil) + defer { presenter.dismiss(); service.releaseWrite(); feature.cancel(); window.contentView = nil; window.close() } + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + layout(host) + if dropdownAnchors(host).count >= 4 && feature.phase == .ready { break } + await Task.yield() + } + func switches(_ view: NSView) -> [NSSwitch] { + if let control = view as? NSSwitch { return [control] } + return view.subviews.flatMap(switches) + } + func pixels(_ control: NSView) throws -> Data { + let bitmap = try #require(control.bitmapImageRepForCachingDisplay(in: control.bounds)) + control.cacheDisplay(in: control.bounds, to: bitmap) + let bytes = try #require(bitmap.bitmapData) + return Data(bytes: bytes, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } + let control = try #require(switches(host).first) + let otherControl = try #require(switches(host).last) + #expect(control.state == .on && control.isEnabled) + let originalPixels = try pixels(control) + let otherPixels = try pixels(otherControl) + try capture(host, name: "skills-menu-before") + let before = feature.listings + var copies = 0 + presenter.show(items: [.action("Copy path") { copies += 1 }], at: NSPoint(x: 800, y: 500), + appearance: NSAppearance(named: .darkAqua), locale: Locale(identifier: "en"), parentWindow: window) + layout(host) + #expect(window.isKeyWindow, "an action menu must retain the settings window's key appearance") + #expect(window.childWindows?.contains(where: { $0.isVisible && !$0.canBecomeKey }) == true) + #expect(feature.listings == before && !feature.isWriting) + #expect(service.toggleRequests.isEmpty, "menu presentation never writes native skill settings") + #expect(control.state == .on && control.isEnabled) + #expect(try pixels(control) == originalPixels, "key-window transfer must not gray an enabled skill") + try capture(host, name: "skills-menu-open") + for code in [UInt16(125), UInt16(36)] { + NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "", charactersIgnoringModifiers: "", + isARepeat: false, keyCode: code))) + } + #expect(copies == 1, "arrow and Return navigation still execute the action once") + #expect(window.childWindows?.contains(where: { $0.isVisible }) != true) + window.resignKey() + layout(host) + #expect(control.state == .on && otherControl.state == .on, "inactive window styling does not turn skills off") + #expect(feature.listings == before && service.toggleRequests.isEmpty) + window.makeKey() + layout(host) + #expect(try pixels(otherControl) == otherPixels, "reactivation restores the original native appearance") + service.holdWrite = true + let item = try #require(feature.visibleItems.first) + feature.setEnabled(false, for: item, context: .init(workspace: service.firstProject)) + #expect(await awaitChange(on: service) { service.toggleRequests.count == 1 }) + layout(host) + let pendingControl = try #require(switches(host).first) + #expect(pendingControl.state == .off, "the native switch reflects the choice before saving finishes") + #expect(!pendingControl.isEnabled, "a real pending write must still disable the control") + #expect(otherControl.state == .on && otherControl.isEnabled, "saving one row must not disable another skill") + #expect(try pixels(otherControl) == otherPixels, "an unrelated enabled switch must keep its appearance") + func progressIndicators(_ view: NSView) -> [NSProgressIndicator] { + if let indicator = view as? NSProgressIndicator { return [indicator] } + return view.subviews.flatMap(progressIndicators) + } + #expect(progressIndicators(host).isEmpty, "a skill toggle does not add a loading spinner") + try capture(host, name: "skills-toggle-pending") + } + + @Test func scopedListsAndNativeDropdownAnchorsFitNarrowAndWideSettings() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + await feature.refresh(.init(workspace: service.firstProject)) + for scheme in [ColorScheme.dark, .light] { + for width in [CGFloat(500), CGFloat(760)] { + feature.scope = "user" + let root = SettingsAppearanceContainer(themePreference: scheme == .dark ? .dark : .light) { + ScrollView { + AgentSkillsSettingsView(feature: feature, workspace: service.firstProject, + openProjects: [service.firstProject, service.secondProject], preferredAgentID: "codex-acp", + onOpen: { _ in }, onReveal: { _ in }, onCopy: { _ in }, onChooseImport: { _, _, _ in nil }) + .padding(16) + } + }.environment(\.locale, Locale(identifier: "zh-Hans")) + let host = NSHostingView(rootView: root) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 640), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { feature.cancel(); window.contentView = nil; window.close() } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + var anchors: [NSView] = [] + while clock.now < deadline { + layout(host) + anchors = dropdownAnchors(host) + if anchors.count >= 5 && feature.phase == .ready { break } + await Task.yield() + } + #expect(anchors.count >= 5, "project, source, import and each row keep native dropdown anchors") + for anchor in anchors { + let rect = anchor.convert(anchor.bounds, to: host) + #expect(rect.minX >= -1 && rect.maxX <= width + 1, "dropdown stays inside a narrow settings page") + } + try capture(host, name: "skills-\(scheme)-\(Int(width))") + feature.scope = "repo" + host.layoutSubtreeIfNeeded() + #expect(feature.visibleItems.count == 2) + } + } + } + + @Test func detailsAndCreationUseExistingSettingsChrome() async throws { + let service = SkillsTestService() + let feature = AgentSkillsFeatureModel(service: service) + let context = AgentSkillsFeatureModel.Context(workspace: service.firstProject) + await feature.refresh(context) + let item = try #require(feature.visibleItems.first) + for scheme in [ColorScheme.dark, .light] { + let views: [(String, AnyView)] = [ + ("detail", AnyView(AgentSkillDetailSheet(item: item, feature: feature, onOpen: {}, onReveal: {}))), + ("create", AnyView(AgentSkillCreateSheet(feature: feature, context: context, scope: "repo", initialAgentID: "codex-acp", source: nil))) + ] + for (name, view) in views { + let host = NSHostingView(rootView: SettingsAppearanceContainer(themePreference: scheme == .dark ? .dark : .light) { + view.environment(\.locale, Locale(identifier: "zh-Hans")) + }) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 560, height: 570), styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.orderFront(nil) + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + if name == "detail" { + #expect(await awaitChange(on: service) { service.readRequests > (scheme == .dark ? 0 : 1) }) + host.layoutSubtreeIfNeeded() + } + #expect(host.fittingSize.width <= 560) + try capture(host, name: "skill-\(name)-\(scheme)") + } + } + feature.cancel() + } + + private func renderedSwitches(_ host: NSView, state: NSControl.StateValue, enabled: Bool) async throws -> [NSSwitch] { + func switches(_ view: NSView) -> [NSSwitch] { + if let control = view as? NSSwitch { return [control] } + return view.subviews.flatMap(switches) + } + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + var controls: [NSSwitch] = [] + while clock.now < deadline { + layout(host) + controls = switches(host) + if controls.count == 2 && controls.first?.state == state && controls.first?.isEnabled == enabled && controls.dropFirst().allSatisfy({ $0.isEnabled }) { return controls } + await Task.yield() + } + Issue.record("Native skill switches did not render the expected state before the local deadline") + throw CancellationError() + } + + private func layout(_ host: NSView) { + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) + host.layoutSubtreeIfNeeded() + } + private func dropdownAnchors(_ view: NSView) -> [NSView] { + // Selectors have their own shared native owner; menus use the general dropdown owner. + if view is LitheDropdownAnchorView || String(describing: type(of: view)) == "LitheSettingsSelectAnchorNSView" { return [view] } + return view.subviews.flatMap(dropdownAnchors) + } + private func capture(_ host: NSView, name: String) throws { + guard let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_SKILLS_SCREENSHOTS"] else { return } + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent(name + ".png")) + } +} + +@MainActor +private final class SkillMenuFocusWindow: NSPanel { + override var canBecomeKey: Bool { true } +} diff --git a/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift b/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift new file mode 100644 index 0000000000..62089808c8 --- /dev/null +++ b/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift @@ -0,0 +1,90 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@Suite("Action menu focus", .serialized) +@MainActor +struct LitheActionMenuFocusTests { + @Test func actionKeysRemainScopedToTheOwnerAndEscapeRestoresItsResponder() throws { + let window = makeWindow() + let editor = NSTextView(frame: window.contentView!.bounds) + window.contentView = editor + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(editor) + let presenter = LitheContextMenuPresenter() + defer { presenter.dismiss(); window.contentView = nil; window.close() } + var calls = 0 + presenter.show(items: [.action("Copy") { calls += 1 }], at: window.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: window) + #expect(window.isKeyWindow) + #expect(window.firstResponder === editor) + try sendKey(125, to: window) + try sendKey(53, to: window) + #expect(calls == 0 && !hasPopup(window)) + #expect(window.firstResponder === editor) + } + + @Test func childActionsReceiveKeysBeforeTheSearchableParentsHandler() throws { + let window = makeWindow() + window.makeKeyAndOrderFront(nil) + let parent = LitheContextMenuPresenter(), child = LitheContextMenuPresenter() + defer { child.dismiss(); parent.dismiss(); window.contentView = nil; window.close() } + var parentKeys = 0, childCalls = 0 + let content = NSHostingController(rootView: Text("Parent") + .frame(width: 200, height: 120) + .litheDropdownKeyHandler { _ in parentKeys += 1; return true }) + parent.show(contentController: content, at: window.frame.origin, appearance: nil, parentWindow: window, onDismiss: {}) + let parentPanel = try #require(window.childWindows?.first(where: { $0.isVisible })) + #expect(parentPanel.isKeyWindow) + child.show(items: [.action("Copy") { childCalls += 1 }], at: parentPanel.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: parentPanel) + #expect(parentPanel.isKeyWindow) + try sendKey(125, to: parentPanel) + try sendKey(36, to: parentPanel) + #expect(childCalls == 1 && parentKeys == 0) + #expect(!hasPopup(parentPanel) && parentPanel.isVisible) + } + + @Test func losingOwnerFocusAndApplicationActivationDismissesTheMenu() { + let window = makeWindow() + window.makeKeyAndOrderFront(nil) + let presenter = LitheContextMenuPresenter() + defer { presenter.dismiss(); window.contentView = nil; window.close() } + func show() { + presenter.show(items: [.action("Copy") {}], at: window.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: window) + } + show() + #expect(hasPopup(window)) + window.resignKey() + #expect(!hasPopup(window)) + window.makeKey() + show() + #expect(hasPopup(window)) + NotificationCenter.default.post(name: NSApplication.didResignActiveNotification, object: NSApp) + #expect(!hasPopup(window)) + } + + private func makeWindow() -> NSWindow { + let window = ActionMenuFocusWindow(contentRect: NSRect(x: 100, y: 100, width: 400, height: 240), + styleMask: [.titled, .nonactivatingPanel], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + return window + } + private func hasPopup(_ window: NSWindow) -> Bool { + window.childWindows?.contains(where: { $0.isVisible }) == true + } + private func sendKey(_ code: UInt16, to window: NSWindow) throws { + NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "", charactersIgnoringModifiers: "", + isARepeat: false, keyCode: code))) + } +} + +// A nonactivating native host can become key in the isolated test process; +// it exercises real AppKit focus without activating a desktop application. +@MainActor +private final class ActionMenuFocusWindow: NSPanel { + override var canBecomeKey: Bool { true } +} diff --git a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift index f0155250b2..d7c0a0a6b0 100644 --- a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift +++ b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift @@ -1,10 +1,308 @@ +import AppKit import Foundation +import SwiftUI import Testing @testable import Lithe /// Exercise discovery -> draft -> feature save -> Core reparse using isolated real files. @MainActor struct McpArgumentRoundTripTests { + @Test func cancellingBeforeTheNativeWorkerStartsDoesNotConnect() async throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let service = try #require(fixture.mcpTools) + let definition = McpServerDefinition(name: "cancelled", transport: .http, command: nil, args: [], env: [], + url: "https://example.invalid/mcp", headers: [], definitionHash: "fixture") + // Both tasks inherit MainActor: cancellation precedes the first probe + // instruction, without a scheduling delay or a public-network fixture. + let task = Task { try await service.probe(definition) } + task.cancel() + do { + _ = try await task.value + Issue.record("A cancelled probe must not publish a result") + } catch { + #expect(error is CancellationError) + } + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_MCP_PROBE_INTEGRATION"] == "1")) + func publicContext7ProbeRunsOnTheNativeWorker() async throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let service = try #require(fixture.mcpTools) + let definition = McpServerDefinition(name: "context7", transport: .http, command: nil, args: [], env: [], + url: "https://mcp.context7.com/mcp", headers: [], definitionHash: "integration") + // Explicit integration opt-in: verifies the real linked Swift/SDK path, + // including proxy/TLS stack depth, without calling any remote tool. + let result = try await service.probe(definition) + #expect(result.tools.contains { $0.name == "resolve-library-id" }) + #expect(result.tools.contains { $0.name == "query-docs" }) + } + + @Test func renamingDoesNotBypassClaudeToolRestrictions() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + try fixture.seed(.claudeCode, arguments: []) + try fixture.externalPolicyChange(.claudeCode) + let feature = fixture.feature(); feature.refresh() + let row = try #require(feature.rows.first) + var draft = feature.draft(for: row); draft.name = "renamed" + #expect(!feature.save(draft)) + #expect(feature.error != nil) + #expect(try fixture.reparse(.claudeCode).name == "alpha") + } + + @Test func codexRenamePreservesOnlyOwnedExactToolRulesAndAllowsReenabling() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = [.codex] + try #require(feature.save(draft)) + // The user owns read's deny and allow-list; Lithe owns only write's deny. + try fixture.codexFilters(allow: ["read", "write"], deny: ["read"]) + feature.refresh() + feature.takeOwnership(of: try #require(feature.rows.first), agent: .codex) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let off = try service.setEnabled(false, tool: "write", target: target, + snapshot: service.policy(for: target, agent: .codex, tools: ["read", "write", "outside"])) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + var renamed = feature.draft(for: try #require(feature.rows.first)); renamed.name = "beta" + try #require(feature.save(renamed)) + #expect(try fixture.reparse(.codex, name: "beta").name == "beta") + let beta = try #require(feature.rows.first) + let betaTarget = McpToolTarget(definition: beta.definition, files: beta.globalSources.map(\.file)) + let policy = try service.policy(for: betaTarget, agent: .codex, tools: ["read", "write", "outside"]) + #expect(policy.tools.first { $0.name == "write" }?.enabled == false) + #expect(policy.tools.first { $0.name == "write" }?.editable == true) + #expect(policy.tools.first { $0.name == "read" }?.editable == false) + #expect(policy.tools.first { $0.name == "outside" }?.editable == false) + let on = try service.setEnabled(true, tool: "write", target: betaTarget, snapshot: policy) + feature.tools.didChangePolicy?(beta, on) + service.release([on]) + #expect(on.tools.first { $0.name == "write" }?.enabled == true) + #expect(on.tools.first { $0.name == "read" }?.enabled == false) + + // Reusing alpha must not inherit the ownership moved to beta. + try #require(feature.save(draft)) + try fixture.codexFilters(allow: ["read", "write"], deny: ["write"]) + feature.refresh() + let alpha = try #require(feature.rows.first { $0.name == "alpha" }) + let alphaPolicy = try service.policy(for: McpToolTarget(definition: alpha.definition, + files: alpha.globalSources.map(\.file)), agent: .codex, tools: ["write"]) + defer { service.release([alphaPolicy]) } + #expect(alphaPolicy.tools.first?.enabled == false) + #expect(alphaPolicy.tools.first?.editable == false) + } + + @Test func compactEditorFitsBothNativeAppearances() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + let entry = try #require(entries.first { $0.id == "lithe.featured/playwright" }) + var draft = try #require(McpServerDraft(entry: entry)); draft.enabledAgents = Set(McpAgent.allCases) + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/mcp-ui") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for dark in [false, true] { + let view = McpServerEditorView(feature: feature, draft: .constant(draft), onClose: {}) + .padding(20).frame(width: 640, height: 500) + .environment(\.colorScheme, dark ? .dark : .light) + .background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + #expect(host.fittingSize == NSSize(width: 640, height: 500)) + host.frame = NSRect(x: 0, y: 0, width: 640, height: 500); host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent("editor-\(dark ? "dark" : "light").png")) + } + } + + @Test(arguments: McpAgent.allCases) + func toolExclusionsRoundTripAndKeepOwnedServerRemovalWorking(agent: McpAgent) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft(); draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = [agent] + try #require(feature.save(draft)) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let policy = try service.policy(for: target, agent: agent, tools: ["read", "write"]) + let off = try service.setEnabled(false, tool: "write", target: target, snapshot: policy) + #expect(off.tools.first { $0.name == "write" }?.enabled == false) + let reopened = try service.policy(for: target, agent: agent, tools: ["read", "write"]) + #expect(reopened.tools.first { $0.name == "write" }?.enabled == false) + let on = try service.setEnabled(true, tool: "write", target: target, snapshot: reopened) + #expect(on.tools.allSatisfy { $0.enabled }) + service.release([off, on]) + feature.refresh() + // Direct adapter changes intentionally do not adopt server ownership. + // The page model advances the matching source hash after its own edit. + let current = try #require(feature.rows.first) + let page = feature.tools + page.didChangePolicy?(row, off) + page.didChangePolicy?(current, on) + #expect(feature.rows.first?.definition.name == "alpha") + try #require(feature.setAgent(agent, enabled: false, for: try #require(feature.rows.first))) + #expect(feature.rows.isEmpty) + } + + @Test(arguments: McpAgent.allCases) + func staleToolPolicySnapshotPreservesExternalNativeEdits(agent: McpAgent) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + try fixture.seed(agent, arguments: []) + let feature = fixture.feature(); feature.refresh() + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let snapshot = try service.policy(for: target, agent: agent, tools: ["read"]) + try fixture.externalPolicyChange(agent) + #expect(throws: (any Error).self) { + try service.setEnabled(false, tool: "read", target: target, snapshot: snapshot) + } + let latest = try service.policy(for: target, agent: agent, tools: ["read"]) + #expect(latest.tools.first?.enabled == false) + #expect(latest.tools.first?.editable == false) + service.release([snapshot, latest]) + } + + @Test(arguments: ["switch", "editor", "delete"]) + func disabledToolsSurviveServerRemovalAndReopening(action: String) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo" + draft.env = [McpKeyValue(key: "TOKEN", value: "fixture-secret-must-not-be-archived")] + draft.enabledAgents = Set(McpAgent.allCases) + try #require(feature.save(draft)) + try fixture.codexFilters(allow: ["read", "write"], deny: ["read"]) + feature.refresh() + feature.takeOwnership(of: try #require(feature.rows.first), agent: .codex) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let names = ["read", "write", "outside-allowlist"] + let policy = try service.policy(for: target, agent: .codex, tools: names) + let off = try service.setEnabled(false, tool: "write", target: target, snapshot: policy) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + let claudeBefore = try fixture.nativeText(.claudeCode) + let current = try #require(feature.rows.first) + switch action { + case "switch": try #require(feature.setAgent(.codex, enabled: false, for: current)) + case "editor": + var edit = feature.draft(for: current); edit.enabledAgents.remove(.codex) + try #require(feature.save(edit)) + default: try #require(feature.delete(current)) + } + #expect(try !fixture.nativeText(.codex).contains("mcp_servers.alpha")) + #expect(!fixture.ledgerText().contains("fixture-secret-must-not-be-archived")) + + // A new page reads durable state, rather than the old policy snapshot. + let reopened = fixture.feature(); reopened.refresh() + if action == "switch" { + try #require(reopened.setAgent(.codex, enabled: true, for: try #require(reopened.rows.first))) + } else { + var add = McpServerDraft(definition: row.definition) + add.enabledAgents = Set(McpAgent.allCases) + try #require(reopened.save(add)) + } + let restored = try #require(reopened.rows.first) + let restoredTarget = McpToolTarget(definition: restored.definition, files: restored.globalSources.map(\.file)) + let restoredPolicy = try service.policy(for: restoredTarget, agent: .codex, tools: names) + defer { service.release([restoredPolicy]) } + #expect(restoredPolicy.tools.allSatisfy { !$0.enabled }) + #expect(restoredPolicy.tools.first { $0.name == "read" }?.editable == false) + #expect(restoredPolicy.tools.first { $0.name == "write" }?.editable == true) + #expect(restoredPolicy.tools.first { $0.name == "outside-allowlist" }?.editable == false) + let claude = try service.policy(for: restoredTarget, agent: .claudeCode, tools: names) + service.release([claude]) + #expect(claude.tools.allSatisfy { $0.enabled }) + if action != "delete" { #expect(try fixture.nativeText(.claudeCode) == claudeBefore) } + + let on = try service.setEnabled(true, tool: "write", target: restoredTarget, snapshot: restoredPolicy) + service.release([on]); reopened.tools.didChangePolicy?(restored, on) + #expect(on.tools.first { $0.name == "write" }?.enabled == true) + #expect(on.tools.first { $0.name == "read" }?.enabled == false) + try #require(reopened.setAgent(.codex, enabled: false, for: try #require(reopened.rows.first))) + } + + @Test func retainedFiltersStayWithTheirNativeProfilePath() throws { + let ledger = ArgumentKeyValueStore() + let first = try ArgumentFixture(ledger: ledger) + defer { first.cleanUp() } + let second = try ArgumentFixture(ledger: ledger) + defer { second.cleanUp() } + var draft = McpServerDraft(); draft.name = "alpha"; draft.command = "/bin/echo" + draft.enabledAgents = Set(McpAgent.allCases) + let original = first.feature() + try #require(original.save(draft)) + try first.codexFilters(allow: [], deny: ["read"]) + original.refresh() + original.takeOwnership(of: try #require(original.rows.first), agent: .codex) + try #require(original.setAgent(.codex, enabled: false, for: try #require(original.rows.first))) + + let otherProfile = second.feature() + draft.enabledAgents = [.codex] + try #require(otherProfile.save(draft)) + #expect(try !second.nativeText(.codex).contains("enabled_tools")) + #expect(try !second.nativeText(.codex).contains("disabled_tools")) + try #require(otherProfile.delete(try #require(otherProfile.rows.first))) + + // Returning to the original native path restores its empty allow-list, + // even after a different profile reused the same name and preferences. + let reopened = first.feature(); reopened.refresh() + try #require(reopened.setAgent(.codex, enabled: true, for: try #require(reopened.rows.first))) + let row = try #require(reopened.rows.first) + let service = try #require(first.mcpTools) + let policy = try service.policy(for: McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)), + agent: .codex, tools: ["read", "write"]) + defer { service.release([policy]) } + #expect(policy.tools.allSatisfy { !$0.enabled && !$0.editable }) + } + + @Test(arguments: McpAgent.allCases, ["github", "deepwiki", "exa", "filesystem", "fetch", "memory"]) + func featuredSetupIsExplicitAndRoundTripsThroughNativeConfiguration(agent: McpAgent, name: String) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + let entry = try #require(entries.first { $0.id == "lithe.featured/\(name)" }) + var draft = try #require(McpServerDraft(entry: entry)) + #expect(draft.enabledAgents.isEmpty) + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.enabledAgents = [agent] + switch draft.setup { + case .bearerToken: + #expect(!feature.save(draft)) + draft.headers[0].value = "Bearer " + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.headers[0].value = "Bearer fixture-token" + case .allowedDirectories: + #expect(!feature.save(draft)) + draft.argumentsText += "\nrelative-folder" + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.argumentsText = try #require(entry.draft).args.joined(separator: "\n") + "\n" + fixture.allowedFolder() + case nil: break + } + try #require(feature.save(draft)) + let reparsed = try fixture.reparse(agent, name: name) + #expect(reparsed.args == draft.arguments) + #expect(reparsed.url == (draft.url.isEmpty ? nil : draft.url)) + #expect(reparsed.headers == draft.headers) + #expect(feature.rows.first?.enabledGlobalAgents == [agent]) + } + @Test( arguments: McpAgent.allCases, [ @@ -94,13 +392,18 @@ struct McpArgumentRoundTripTests { private final class ArgumentFixture: McpServerLibraryActions { private let root: URL private let store: MacAgentMcpConfigurationStore - private let ledger = ArgumentKeyValueStore() + private let ledger: ArgumentKeyValueStore + private lazy var toolService = MacMcpTools(core: RustCoreBridge(), store: store, ledger: ledger, + storage: ArgumentFileStorage(root: root), environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path]) + var mcpTools: (any McpToolsManaging)? { toolService } + var mcpRegistry: (any McpRegistryProviding)? { MacMcpRegistry(core: RustCoreBridge()) } var mcpConfigurationStore: (any McpConfigurationStore)? { store } var mcpWorkspaceURL: URL? { nil } var mcpLedgerStore: any KeyValueStore { ledger } - init() throws { + init(ledger: ArgumentKeyValueStore = ArgumentKeyValueStore()) throws { + self.ledger = ledger let directory = FileManager.default.temporaryDirectory .appendingPathComponent("mcp-arguments-\(UUID().uuidString)", isDirectory: true) root = directory @@ -151,12 +454,50 @@ private final class ArgumentFixture: McpServerLibraryActions { try Data(content.utf8).write(to: file.url) } - func reparse(_ agent: McpAgent) throws -> McpServerDefinition { + func hasConfiguration(_ agent: McpAgent) throws -> Bool { + try store.read(file(for: agent)) != nil + } + + func nativeText(_ agent: McpAgent) throws -> String { try store.read(file(for: agent)) ?? "" } + + func codexFilters(allow: [String], deny: [String]) throws { + let file = try file(for: .codex) + let encode = { (values: [String]) in String(decoding: try JSONEncoder().encode(values), as: UTF8.self) } + let before = try nativeText(.codex) + let header = "[mcp_servers.alpha]\n" + try #require(before.contains(header)) + let text = before.replacingOccurrences(of: header, with: header + "enabled_tools = \(try encode(allow))\ndisabled_tools = \(try encode(deny))\n") + try Data(text.utf8).write(to: file.url) + } + + func ledgerText() -> String { + ledger.data(forKey: "settings.mcpServerRegistrations").map { String(decoding: $0, as: UTF8.self) } ?? "" + } + + func allowedFolder() throws -> String { + let directory = root.appendingPathComponent("allowed folder", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.path + } + + func externalPolicyChange(_ agent: McpAgent) throws { + if agent == .codex { + let file = try file(for: agent) + let original = try #require(try store.read(file)) + try Data((original + "\ndisabled_tools = ['read']\n").utf8).write(to: file.url) + } else { + let path = root.appendingPathComponent("claude/settings.json") + try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(#"{"keep":"external","permissions":{"deny":["mcp__alpha__read"]}}"#.utf8).write(to: path) + } + } + + func reparse(_ agent: McpAgent, name: String = "alpha") throws -> McpServerDefinition { let file = try file(for: agent) let content = try #require(try store.read(file)) let definitions = try store.parse(file, content: content) try #require(definitions.count == 1) - return try #require(definitions.first { $0.name == "alpha" }) + return try #require(definitions.first { $0.name == name }) } func cleanUp() { diff --git a/macos/Tests/LitheTests/McpBatchTransactionTests.swift b/macos/Tests/LitheTests/McpBatchTransactionTests.swift index 54832fda5d..53e1d212c5 100644 --- a/macos/Tests/LitheTests/McpBatchTransactionTests.swift +++ b/macos/Tests/LitheTests/McpBatchTransactionTests.swift @@ -75,6 +75,39 @@ struct McpBatchTransactionTests { #expect(feature.rows.isEmpty) } + @Test + func failedRenameKeepsToolOwnershipUntilTheWholeBatchCommits() throws { + let fixture = try BatchFixture() + defer { fixture.cleanUp() } + let feature = McpServerLibraryFeatureModel(model: fixture) + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = Set(McpAgent.allCases) + try #require(feature.save(draft)) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let off = try service.setEnabled(false, tool: "write", target: target, + snapshot: service.policy(for: target, agent: .codex, tools: ["write"])) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + let before = fixture.ledger.values + fixture.store.written.removeAll() + fixture.store.beforeSecondWrite = { _, current in + throw McpConfigurationError.notWritable(current.url.path) + } + var edit = feature.draft(for: try #require(feature.rows.first)); edit.name = "beta" + #expect(!feature.save(edit)) + #expect(fixture.store.restored.count == 1) + #expect(NSDictionary(dictionary: fixture.ledger.values).isEqual(to: before)) + let restored = try #require(feature.rows.first) + #expect(restored.name == "alpha") + let restoredTarget = McpToolTarget(definition: restored.definition, files: restored.globalSources.map(\.file)) + let policy = try service.policy(for: restoredTarget, agent: .codex, tools: ["write"]) + defer { service.release([policy]) } + #expect(policy.tools.first?.enabled == false) + #expect(policy.tools.first?.editable == true) + } + @Test func failedProjectRegistrationDoesNotPublishEitherAgentOrLedger() throws { let fixture = try BatchFixture() @@ -104,6 +137,9 @@ private final class BatchFixture: McpServerLibraryActions { let root: URL let store: FailingBatchStore let ledger = BatchLedger() + private lazy var tools = MacMcpTools(core: RustCoreBridge(), store: store, ledger: ledger, + storage: BatchFileStorage(root: root), environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path]) + var mcpTools: (any McpToolsManaging)? { tools } var mcpWorkspaceURL: URL? { root.appendingPathComponent("project") } var mcpConfigurationStore: (any McpConfigurationStore)? { store } var mcpLedgerStore: any KeyValueStore { ledger } diff --git a/macos/Tests/LitheTests/McpRegistryIntegrationTests.swift b/macos/Tests/LitheTests/McpRegistryIntegrationTests.swift new file mode 100644 index 0000000000..bc22bcdb39 --- /dev/null +++ b/macos/Tests/LitheTests/McpRegistryIntegrationTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import Lithe + +/// Opt-in public-network verification; ordinary unit tests remain offline. +@MainActor +struct McpRegistryIntegrationTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_RUN_MCP_REGISTRY_INTEGRATION"] == "1")) + func explicitlySelectedDirectRouteReadsAndPaginatesTheOfficialRegistry() async throws { + let registry = MacMcpRegistry(core: RustCoreBridge()) + let first = try await registry.search(query: "mcp", cursor: nil, route: .direct) + #expect(!first.entries.isEmpty) + let cursor = try #require(first.nextCursor) + let second = try await registry.search(query: "mcp", cursor: cursor, route: .direct) + #expect(!second.entries.isEmpty) + #expect(Set(first.entries.map(\.id)) != Set(second.entries.map(\.id))) + } +} diff --git a/macos/Tests/LitheTests/McpRegistryTests.swift b/macos/Tests/LitheTests/McpRegistryTests.swift new file mode 100644 index 0000000000..14908261a2 --- /dev/null +++ b/macos/Tests/LitheTests/McpRegistryTests.swift @@ -0,0 +1,194 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +struct McpRegistryTests { + @Test func nativeDirectoryCardsReflowInLightAndDarkAppearances() throws { + struct Payload: Encodable {} + let root = DeclarativePluginTestSupport.repositoryRoot + let page: McpRegistryPage = try RustCoreBridge().executeResult(command: "mcp.featuredRegistry", payload: Payload()).get() + let directory = root.appendingPathComponent(".artifacts/mcp-discovery") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for dark in [false, true] { + var heights: [CGFloat] = [] + for width: CGFloat in [420, 780] { + let view = McpRegistryCardsView(entries: page.entries, add: { _ in }) + .padding(16).frame(width: width) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.locale, Locale(identifier: "zh-Hans")) + .background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + let size = host.fittingSize + #expect(abs(size.width - width) < 1) + heights.append(size.height) + host.frame = NSRect(origin: .zero, size: size) + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: directory.appendingPathComponent("cards-\(dark ? "dark" : "light")-\(Int(width)).png")) + } + #expect(heights[0] > heights[1], "Narrow settings pages must stack the catalog cards.") + } + } + + @Test func landingCatalogDoesNotDependOnRegistryConnectivity() async throws { + let registry = RegistryStub() + registry.featuredEntries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + registry.fail = true + let feature = McpRegistryFeatureModel(registry: registry) + #expect(feature.entries.count == 10) + #expect(feature.entries.allSatisfy { $0.source == .featured && $0.draft != nil }) + await feature.search(" ") + #expect(registry.requests.isEmpty) + #expect(feature.entries.count == 10 && feature.error == nil) + #expect(!feature.isLoading && feature.nextCursor == nil) + } + + @Test func featuredMarksLoadOfflineAndPublicEntriesUseTheFallback() throws { + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + for entry in entries { + let image = try #require(McpBrandIconLoader.image(for: entry), "Missing bundled mark for \(entry.id)") + #expect(!image.isTemplate) + #expect(image.size == NSSize(width: 28, height: 28)) + var publicCopy = entry + publicCopy.source = .registry + #expect(McpBrandIconLoader.filename(for: publicCopy) == nil) + #expect(McpBrandIconLoader.image(for: publicCopy) == nil) + publicCopy.setup = .bearerToken + #expect(McpServerDraft(entry: publicCopy)?.setup == nil) + } + } + + @Test func sharedSetupFixtureDecodesWithoutSelectingAgentsOrInventingInputs() throws { + let path = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/mcp/featured-setup-v1.json") + let page = try JSONDecoder().decode(McpRegistryPage.self, from: Data(contentsOf: path)) + #expect(page.entries.map(\.setup) == [.bearerToken, .allowedDirectories]) + let token = try #require(McpServerDraft(entry: page.entries[0])) + let folders = try #require(McpServerDraft(entry: page.entries[1])) + #expect(token.headers == [McpKeyValue(key: "Authorization", value: "")]) + #expect(folders.arguments == ["-y", "@example/filesystem@1.2.3"]) + #expect(token.enabledAgents.isEmpty && folders.enabledAgents.isEmpty) + } + + @Test func connectionFailureKeepsMatchesAndDirectRetryMustBeExplicit() async throws { + let registry = RegistryStub() + registry.featuredEntries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + registry.failure = McpRegistryError.secureConnection + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search("Context7") + #expect(feature.entries.map(\.title) == ["Context7"]) + #expect(feature.error != nil && feature.canRetryDirectly) + #expect(registry.routes == [.system]) + + // The route changes only after a second explicit action, never on failure. + registry.failure = nil + registry.returnedEntries = [] + await feature.search("Context7", route: .direct) + #expect(registry.routes == [.system, .direct]) + #expect(feature.error == nil && !feature.canRetryDirectly) + #expect(feature.entries.map(\.title) == ["Context7"]) + } + + @Test func registryAndFeaturedEntriesShareOneConnectionCard() async throws { + let registry = RegistryStub() + let featured = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + registry.featuredEntries = featured + registry.returnedEntries = [McpRegistryEntry(id: "io.example/context7", title: "Registry copy", summary: "", version: "", website: nil, draft: featured[0].draft)] + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search("Context7") + #expect(feature.entries.count == 1) + #expect(feature.entries[0].source == .featured) + #expect(feature.entries[0].publisher == "Upstash") + } + + @Test func switchingRoutesDoesNotReuseAnotherRoutesPaginationCursor() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search("docs") + await feature.search("docs", loadMore: true, route: .direct) + #expect(registry.routes == [.system, .direct]) + #expect(registry.requests[1].1 == nil) + #expect(feature.entries.map(\.id) == ["one"]) + } + + @Test func coreRegistryFixtureProducesReviewableDrafts() throws { + struct Payload: Encodable { let content: String } + let url = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/mcp-registry-page.json") + let page: McpRegistryPage = try RustCoreBridge().executeResult(command: "mcp.parseRegistry", payload: Payload(content: String(contentsOf: url, encoding: .utf8))).get() + #expect(page.entries.count == 3) + #expect(page.nextCursor == "opaque/+ cursor?") + let draft = McpServerDraft(definition: try #require(page.entries[1].draft)) + #expect(draft.arguments == ["-y", "@example/browser@2.0.0"]) + #expect(draft.enabledAgents.isEmpty) + #expect(draft.originalSources.isEmpty) + #expect(page.entries[2].draft == nil) + } + + @Test func paginationDeduplicatesAndStopsRepeatedCursors() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search(" docs ") + #expect(feature.entries.map(\.id) == ["one"]) + #expect(feature.nextCursor == "cursor") + await feature.search("docs", loadMore: true) + #expect(feature.entries.map(\.id) == ["one", "two"]) + #expect(feature.nextCursor == nil) + #expect(registry.requests.map(\.0) == ["docs", "docs"]) + #expect(registry.requests[1].1 == "cursor") + registry.fail = true + await feature.search("other") + #expect(feature.entries.isEmpty && feature.error != nil && !feature.isLoading) + } + + @Test func cancelledSearchNeverPublishesItsResponse() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + let task = Task { await feature.search("cancelled") } + task.cancel() + await task.value + #expect(feature.entries.isEmpty) + #expect(feature.error == nil) + #expect(!feature.isLoading) + } + + @Test func anOlderResponseCannotReplaceANewerSearch() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + registry.beforeFirstResponse = { await feature.search("new") } + await feature.search("old") + #expect(feature.entries.map(\.id) == ["one"]) + #expect(registry.requests.map(\.0) == ["old", "new"]) + #expect(!feature.isLoading && feature.error == nil) + } +} + +@MainActor +private final class RegistryStub: McpRegistryProviding { + var requests: [(String, String?)] = [] + var fail = false + var failure: (any Error)? + var featuredEntries: [McpRegistryEntry] = [] + var returnedEntries: [McpRegistryEntry]? + var routes: [McpRegistryRoute] = [] + var beforeFirstResponse: (() async -> Void)? + func featured() throws -> McpRegistryPage { McpRegistryPage(entries: featuredEntries, nextCursor: nil) } + func search(query: String, cursor: String?, route: McpRegistryRoute) async throws -> McpRegistryPage { + requests.append((query, cursor)) + routes.append(route) + if let callback = beforeFirstResponse { + beforeFirstResponse = nil + await callback() + return McpRegistryPage(entries: [McpRegistryEntry(id: "old", title: "old", summary: "", version: "1", website: nil, draft: nil)], nextCursor: nil) + } + if let failure { throw failure } + if fail { throw URLError(.notConnectedToInternet) } + let entries = returnedEntries ?? (cursor == nil ? ["one"] : ["one", "two"]).map { + McpRegistryEntry(id: $0, title: $0, summary: "", version: "1", website: nil, draft: nil) + } + return McpRegistryPage(entries: entries, nextCursor: "cursor") + } +} diff --git a/macos/Tests/LitheTests/McpServerLibraryTests.swift b/macos/Tests/LitheTests/McpServerLibraryTests.swift index 5e6233727b..68095feea7 100644 --- a/macos/Tests/LitheTests/McpServerLibraryTests.swift +++ b/macos/Tests/LitheTests/McpServerLibraryTests.swift @@ -8,6 +8,18 @@ import Testing /// ownership ledger, backups and project registration around it. @MainActor struct McpServerLibraryTests { + @Test func jsonImportPreviewsWithoutWritingOrTakingOwnership() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let drafts = try feature.importedDrafts(#"{"mcpServers":{"demo":{"command":"npx","args":["-y","@example/server","space value"],"env":{"TOKEN":"private"}},"remote":{"type":"http","url":"https://example.invalid/mcp"}}}"#) + #expect(drafts.map(\.name) == ["demo", "remote"]) + #expect(drafts[0].arguments == ["-y", "@example/server", "space value"]) + #expect(drafts.allSatisfy { $0.enabledAgents.isEmpty && $0.originalSources.isEmpty }) + #expect(!FileManager.default.fileExists(atPath: fixture.codexURL.path)) + #expect(!FileManager.default.fileExists(atPath: fixture.claudeURL.path)) + #expect(throws: (any Error).self) { try feature.importedDrafts("invalid json private") } + } @Test(arguments: McpAgent.allCases, [false, true]) func staleEditorCannotOverwriteAnotherWindowsSave(agent: McpAgent, refreshBeforeSave: Bool) throws { let fixture = try Fixture() diff --git a/macos/Tests/LitheTests/McpToolsFeatureTests.swift b/macos/Tests/LitheTests/McpToolsFeatureTests.swift new file mode 100644 index 0000000000..7468e9ebd1 --- /dev/null +++ b/macos/Tests/LitheTests/McpToolsFeatureTests.swift @@ -0,0 +1,162 @@ +import AppKit +import Combine +import Foundation +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +struct McpToolsFeatureTests { + @Test func explicitDetectionPublishesRealInventoryAndKeepsAgentsIndependent() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + let row = ToolStub.row() + #expect(service.probes == 0) + let finished = TestGate() + let observer = feature.$states.sink { state in if state[row.id]?.isReady == true { finished.open() } } + defer { observer.cancel(); feature.leavePage() } + let task = try #require(feature.detect(row)) + #expect(await finished.waitUntilOpen()) + await task.value + #expect(service.probes == 1) + #expect(feature.state(for: row).tools.map(\.name) == ["read"]) + feature.setEnabled(false, tool: "read", agent: .codex, row: row) + #expect(feature.state(for: row).policies[.codex]?.tools.first?.enabled == false) + #expect(feature.state(for: row).policies[.claudeCode]?.tools.first?.enabled == true) + service.fails = true + feature.setEnabled(false, tool: "read", agent: .claudeCode, row: row) + #expect(feature.state(for: row).error != nil) + #expect(feature.state(for: row).policies[.claudeCode]?.tools.first?.enabled == true) + } + + @Test func leavingPageCancelsTheProbeAndRejectsItsLateInventory() async throws { + let service = ToolStub(); service.waits = true + let feature = McpToolsFeatureModel(service: service) + let row = ToolStub.row() + let task = try #require(feature.detect(row)) + defer { service.release.open(); task.cancel(); feature.leavePage() } + #expect(await service.started.waitUntilOpen()) + feature.leavePage() + service.release.open() + await task.value + #expect(feature.states.isEmpty) + #expect(service.policies == 0) + } + + @Test func registrationChangesReloadLocalPoliciesAndReleaseOldSnapshotsWithoutProbing() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + defer { feature.leavePage() } + let codex = ToolStub.row(agents: [.codex]) + let task = try #require(feature.detect(codex)) + await task.value + let original = try #require(feature.state(for: codex).policies[.codex]) + let both = ToolStub.row() + #expect(codex.id == both.id) + feature.reconcile([both]) + let combined = feature.state(for: both) + #expect(combined.isReady && combined.error == nil) + #expect(Set(combined.policies.keys) == Set(McpAgent.allCases)) + #expect(combined.policies[.claudeCode]?.tools.first?.enabled == true) + #expect(combined.policies[.claudeCode]?.tools.first?.editable == true) + #expect(service.released.contains(original.id)) + let claude = try #require(combined.policies[.claudeCode]) + feature.reconcile([codex]) + #expect(Set(feature.state(for: codex).policies.keys) == [.codex]) + #expect(service.released.contains(claude.id)) + let calls = service.policies + feature.reconcile([codex]) + #expect(service.policies == calls, "unchanged sources do not reload") + #expect(service.probes == 1) + #expect(feature.state(for: codex).tools.map(\.name) == ["read"]) + } + + @Test func registrationChangesDuringDetectionUseTheLatestSources() async throws { + let service = ToolStub(); service.waits = true + let feature = McpToolsFeatureModel(service: service) + let task = try #require(feature.detect(ToolStub.row(agents: [.codex]))) + defer { service.release.open(); task.cancel(); feature.leavePage() } + #expect(await service.started.waitUntilOpen()) + let both = ToolStub.row() + feature.reconcile([both]) + service.release.open() + await task.value + #expect(Set(feature.state(for: both).policies.keys) == Set(McpAgent.allCases)) + #expect(feature.state(for: both).error == nil) + #expect(service.probes == 1) + } + + @Test func changedRegistrationPolicyFailuresAreVisibleAndKeepOtherAgentsUsable() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + defer { feature.leavePage() } + let codex = ToolStub.row(agents: [.codex]) + let task = try #require(feature.detect(codex)) + await task.value + service.policyFailure = .claudeCode + let both = ToolStub.row() + feature.reconcile([both]) + #expect(feature.state(for: both).error != nil) + #expect(feature.state(for: both).policies[.codex]?.tools.first?.enabled == true) + #expect(feature.state(for: both).policies[.claudeCode] == nil) + service.policyFailure = nil + feature.loadPolicies(both) + #expect(feature.state(for: both).error == nil) + #expect(feature.state(for: both).policies[.claudeCode]?.tools.first?.editable == true) + #expect(service.probes == 1) + } + + @Test func nativeToolPanelFitsWideAndNarrowDarkAndLightLayouts() async throws { + let service = ToolStub(); let feature = McpToolsFeatureModel(service: service); let row = ToolStub.row() + let ready = TestGate() + let observer = feature.$states.sink { states in if states[row.id]?.isReady == true { ready.open() } } + defer { observer.cancel(); feature.leavePage() } + let task = try #require(feature.detect(row)); #expect(await ready.waitUntilOpen()); await task.value + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/mcp-ui") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for width: CGFloat in [420, 760] { + for dark in [false, true] { + let view = McpToolsPanel(feature: feature, row: row).padding(16).frame(width: width) + .environment(\.colorScheme, dark ? .dark : .light).background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view); host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + let size = host.fittingSize; #expect(abs(size.width - width) < 1) + host.frame = NSRect(origin: .zero, size: size); host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent("tools-\(dark ? "dark" : "light")-\(Int(width)).png")) + } + } + } +} + +@MainActor +private final class ToolStub: McpToolsManaging { + let started = TestGate(), release = TestGate() + var waits = false, fails = false + var probes = 0, policies = 0 + var policyFailure: McpAgent? + var released: [UUID] = [] + func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult { + probes += 1; started.open() + if waits { _ = await release.waitUntilOpen() } + return McpProbeResult(tools: [McpToolDescription(name: "read", description: "Read the current library documentation.")]) + } + func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot { + policies += 1 + if policyFailure == agent { throw CocoaError(.fileReadUnknown) } + return McpToolPolicySnapshot(id: UUID(), agent: agent, tools: tools.map { McpToolPermission(name: $0, enabled: true, editable: true) }, previousSourceHash: nil, sourceHash: nil) + } + func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot { + if fails { throw CocoaError(.fileWriteUnknown) } + return McpToolPolicySnapshot(id: UUID(), agent: snapshot.agent, tools: [McpToolPermission(name: tool, enabled: enabled, editable: true)], previousSourceHash: nil, sourceHash: nil) + } + func release(_ snapshots: [McpToolPolicySnapshot]) { released += snapshots.map(\.id) } + func commandLine(command: String, args: [String]) throws -> String { command } + func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) { (line, []) } + static func row(agents: Set = Set(McpAgent.allCases)) -> McpServerRow { + let server = McpServerDefinition(name: "docs", transport: .http, command: nil, args: [], env: [], url: "https://example.invalid/mcp", headers: [], definitionHash: "fixture") + let sources = McpAgent.allCases.filter { agents.contains($0) }.map { agent in McpServerSource(file: McpConfigurationFile(agent: agent, scope: .global, + url: URL(fileURLWithPath: "/fixture/\(agent.rawValue)"), projectPath: nil), definitionHash: "fixture", isOwnedByLithe: false) } + return McpServerRow(id: server.id, definition: server, sources: sources, hasDefinitionConflict: false) + } +} diff --git a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift index 8caa8d942f..029c9ecb29 100644 --- a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift +++ b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift @@ -268,7 +268,15 @@ struct SearchEverywhereBehaviorTests { try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { scope in model.openProjectDirectly(root) try #require(await loaded.waitUntilOpen()) + // Project inventory can arrive before the module teardown started + // by opening the workspace finishes. Join that boundary before + // injecting the Safe Mode capability, or a late shutdown removes + // it between queries and all later results become empty. + try await scope.run("initial workspace module teardown", timeout: .seconds(5)) { + await model.awaitModuleRuntimeShutdown() + } model.cacheModuleCapability(SearchModuleCapability(feature: feature), id: .searchWorkspace, moduleID: .search) + try #require(model.searchFeatureIfActive === feature) var bare: [AgentFileMention] = [], named: [AgentFileMention] = [] try await scope.run("bare and named agent mentions", timeout: .seconds(5)) { bare = await model.agentFileMentions(matching: "") diff --git a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift index e6eeda279c..0a7a285737 100644 --- a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift +++ b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift @@ -11,6 +11,12 @@ import Testing @Suite("Settings search vocabulary") @MainActor struct SettingsSearchVocabularyTests { + @Test func globalInstructionsAreSearchableByNativeFileAndChineseName() throws { + let translations = try simplifiedChineseTranslations() + for query in ["Global instructions", "AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "全局指令"] { + #expect(SettingsSearchVocabulary.matches(query: query, category: .instructions, localize: { translations[$0] ?? $0 })) + } + } @Test func everySettingsPageKeepsSearchTerms() { for category in SettingsCategory.allCases { #expect( @@ -43,6 +49,12 @@ struct SettingsSearchVocabularyTests { #expect(!SettingsSearchVocabulary.matches(query: "ACP adapter", category: .providers)) } + @Test func skillsPageMatchesSkillScopeAndImportQueries() { + for query in ["Skills", "SKILL.md", "Global skills", "Project skills", "Import skill"] { + #expect(SettingsSearchVocabulary.matches(query: query, category: .skills)) + } + } + @Test func editorPageMatchesTheNewFontQueries() { #expect(SettingsSearchVocabulary.matches(query: "font", category: .editor)) #expect(SettingsSearchVocabulary.matches(query: "fonts", category: .editor)) @@ -103,6 +115,8 @@ struct SettingsSearchVocabularyTests { let translations = try simplifiedChineseTranslations() let localize: (String) -> String = { translations[$0] ?? $0 } + #expect(SettingsSearchVocabulary.matches(query: "技能", category: .skills, localize: localize)) + #expect(SettingsSearchVocabulary.matches(query: "全局技能", category: .skills, localize: localize)) #expect(translations["Editor tabs"] == "编辑器标签栏") #expect(translations["Font"] == "字体") #expect(translations["Search fonts"] == "搜索字体") diff --git a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift index 88b3268839..333ef1800a 100644 --- a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift +++ b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift @@ -37,6 +37,6 @@ struct SettingsSidebarLayoutTests { #expect(SettingsGroup.build.categories == [.project, .run]) #expect(SettingsGroup.languages.categories == [.lsp]) #expect(SettingsGroup.tools.categories == [.terminal, .diagnostics]) - #expect(SettingsGroup.ai.categories == [.mcp, .ai, .agents, .providers]) + #expect(SettingsGroup.ai.categories == [.mcp, .ai, .instructions, .agents, .skills, .providers]) } } diff --git a/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift b/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift new file mode 100644 index 0000000000..f5b3be6e15 --- /dev/null +++ b/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("Shared instruction synchronization") +@MainActor +struct SharedInstructionsFeatureTests { + @Test func loadingAndExpandingIsReadOnlyAndKeepsIndependentDrafts() async throws { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + #expect(feature.sharedDraft == "Shared original") + #expect(feature.files.count == 2) + feature.toggleEditor(.codex) + feature.edit("Unsaved native draft") + feature.toggleEditor(.codex) + #expect(!feature.isEditorExpanded) + feature.toggleEditor(.claude) + feature.toggleEditor(.codex) + #expect(feature.draft == "Unsaved native draft") + #expect(!feature.canToggleShared(.codex)) + feature.editShared("New shared text") + #expect(feature.canSaveShared) + #expect(!feature.canToggleShared(.claude), "save source changes before switching targets") + await feature.saveShared() + #expect(feature.sharedLibrary?.file.content == "New shared text") + #expect(feature.draft == "Unsaved native draft") + #expect(await service.operations == ["save"]) + } + + @Test func rapidAgentChoicesUseFreshSnapshotsAndOneFailureDoesNotClearAnotherChoice() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + await service.setFailsCodex(true) + await service.onToggle { + await MainActor.run { + #expect(!feature.canToggleShared(.codex)) + #expect(feature.canToggleShared(.claude), "another row stays enabled and retains its native colors") + } + await feature.setSharedEnabled(true, agent: .claude) + } + await feature.setSharedEnabled(true, agent: .codex) + #expect(feature.sharedState(.codex)?.enabled == false) + #expect(feature.sharedState(.claude)?.enabled == true) + #expect(feature.sharedSyncErrors[.codex] == "External file changed") + #expect(feature.pendingSharedAgents.isEmpty) + #expect(await service.operations == ["codex-acp", "claude-acp"]) + await service.setFailsCodex(false) + await feature.setSharedEnabled(true, agent: .codex) + #expect(feature.sharedState(.codex)?.enabled == true) + #expect(feature.sharedSyncErrors[.codex] == nil) + } + + @Test func sourceConflictsKeepDraftAndFailedReloadDoesNotDiscardIt() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + feature.editShared("Keep this shared draft") + await service.setFailsSource(true) + await feature.saveShared() + #expect(feature.sharedPhase == .failed("External file changed")) + #expect(feature.sharedDraft == "Keep this shared draft") + await feature.loadShared(reload: true) + #expect(feature.sharedDraft == "Keep this shared draft") + await service.setFailsSource(false) + await feature.loadShared(reload: true) + #expect(feature.sharedDraft == "Shared original") + #expect(!feature.isSharedDirty) + } + + @Test func cancellingTheOwnerDropsQueuedSwitchesAndDoesNotWriteThemLater() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + let started = TestGate(), released = TestGate() + await service.onToggle { + started.open() + let wasReleased = await released.waitUntilOpen() + if !Task.isCancelled { #expect(wasReleased) } + } + await feature.loadShared() + let task = Task { await feature.setSharedEnabled(true, agent: .codex) } + defer { released.open(); task.cancel() } + #expect(await started.waitUntilOpen()) + await feature.setSharedEnabled(true, agent: .claude) + task.cancel() + released.open() + await task.value + #expect(feature.pendingSharedAgents.isEmpty) + #expect(feature.sharedState(.claude)?.enabled == false) + #expect(await service.operations == ["codex-acp"]) + } +} + +private actor SharedInstructionsStub: AgentInstructionsService { + struct Conflict: LocalizedError { var errorDescription: String? { "External file changed" } } + var operations: [String] = [] + private var text = "Shared original" + private var enabled: Set = [] + private var failsCodex = false, failsSource = false + private var callback: (@Sendable () async -> Void)? + func onToggle(_ value: @escaping @Sendable () async -> Void) { callback = value } + func setFailsCodex(_ value: Bool) { failsCodex = value } + func setFailsSource(_ value: Bool) { failsSource = value } + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { native(agent) } + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { file } + func readShared() async throws -> SharedInstructionLibrary { + if failsSource { throw Conflict() } + return library() + } + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + operations.append("save") + if failsSource { throw Conflict() } + text = content + return self.library() + } + func setSharedEnabled(_ value: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + operations.append(agent.rawValue) + let called = callback; callback = nil + await called?() + try Task.checkCancellation() + if failsCodex && agent == .codex { throw Conflict() } + if value { enabled.insert(agent) } else { enabled.remove(agent) } + return self.library() + } + private func native(_ agent: AgentInstructionKind) -> AgentInstructionFile { + .init(agentId: agent.rawValue, path: "/fixture/\(agent.rawValue)/instructions.md", + content: "Own \(agent.title)", exists: true, revision: "native-\(agent.rawValue)") + } + private func library() -> SharedInstructionLibrary { + .init(file: .init(agentId: "shared", path: "/fixture/library/Shared.md", content: text, + exists: true, revision: "source-\(text)"), + agents: AgentInstructionKind.allCases.map { agent in + SharedInstructionAgent(agentId: agent.rawValue, file: native(agent), enabled: enabled.contains(agent), + needsSync: false, ownLineCount: 1, issue: nil) + }) + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 07cf58ec9e..4d08b2057f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -346,6 +346,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -1998,21 +2004,28 @@ dependencies = [ "agent-client-protocol", "bytes", "flate2", + "fs2", "futures", + "http", "http-body-util", "hyper", "hyper-util", "kill_tree", "libc", "reqwest", + "rmcp", "semver", "serde", "serde_json", + "serde_yaml_ng", + "sha2", + "sse-stream", "tokio", "tokio-util", "tungstenite", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -2036,6 +2049,7 @@ dependencies = [ "serde_yaml_ng", "sha1", "sha2", + "shell-words", "toml", "toml_edit", "tree-sitter", @@ -3072,16 +3086,21 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" dependencies = [ + "base64 0.23.1", + "bytes", "chrono", "futures", + "http", "indexmap 2.14.0", "pastey", "pin-project-lite", "schemars 1.2.2", "serde", "serde_json", + "sse-stream", "thiserror 2.0.20", "tokio", + "tokio-stream", "tokio-util", "tracing", "uuid", @@ -3867,6 +3886,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/rust/lithe-agent-host/Cargo.toml b/rust/lithe-agent-host/Cargo.toml index e8dcf5b1c8..bfef35936f 100644 --- a/rust/lithe-agent-host/Cargo.toml +++ b/rust/lithe-agent-host/Cargo.toml @@ -9,6 +9,7 @@ description = "Shared ACP session and agent process host for Lithe" agent-client-protocol = { version = "=2.2.0", features = ["unstable_end_turn_token_usage", "unstable_session_notices"] } bytes = "1" futures = "0.3" +fs2 = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } @@ -16,7 +17,12 @@ hyper-util = { version = "0.1", features = ["tokio"] } kill_tree = { version = "0.2", features = ["tokio"] } serde = { version = "1", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +rmcp = { version = "=3.5.0", default-features = false, features = ["client", "transport-async-rw", "transport-streamable-http-client"] } +http = "1" +sse-stream = "=0.2.6" serde_json = "1" +sha2 = "0.10" +serde_yaml_ng = "0.10.0" semver = "1" tokio = { version = "1", features = ["io-util", "net", "process", "rt", "sync", "time"] } tokio-util = { version = "0.7", features = ["compat"] } @@ -27,6 +33,9 @@ uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] tokio = { version = "1", features = ["macros", "test-util"] } tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } diff --git a/rust/lithe-agent-host/src/codex_history.rs b/rust/lithe-agent-host/src/codex_history.rs index f977a50ae5..e0b78c9727 100644 --- a/rust/lithe-agent-host/src/codex_history.rs +++ b/rust/lithe-agent-host/src/codex_history.rs @@ -11,6 +11,7 @@ use serde_json::{json, Value}; use tokio::io::{AsyncBufRead, AsyncWrite, AsyncWriteExt, BufReader}; const PROBE_TIMEOUT: Duration = Duration::from_secs(30); +const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(50); const PAGE_SIZE: u32 = 50; /// Source kinds codex-acp lists, so archived and active history share a scope. @@ -37,6 +38,80 @@ pub(crate) struct CodexHistory { } impl CodexHistory { + /// Validate the selected path against native discovery before changing its + /// configuration, then return freshly reported state from the same server. + pub(crate) async fn set_skill_enabled( + executable: PathBuf, + cwd: PathBuf, + path: PathBuf, + enabled: bool, + cancelled: &dyn Fn() -> bool, + ) -> Result { + let probe = Self { + executable, + cwd: cwd.clone(), + model_provider: SUBSCRIPTION_PROVIDER, + }; + probe + .exchange_cancellable( + |mut input, mut output| async move { + let params = json!({"cwds": [cwd], "forceReload": true}); + let before = subscription::request( + &mut input, + &mut output, + 2, + "skills/list", + params.clone(), + ) + .await?; + let path = crate::skills::codex_skill_path(&before, &cwd, &path) + .ok_or("unknown skill")?; + if cancelled() { + return Err("cancelled"); + } + subscription::request( + &mut input, + &mut output, + 3, + "skills/config/write", + json!({"path":path,"enabled":enabled}), + ) + .await?; + subscription::request(&mut input, &mut output, 4, "skills/list", params).await + }, + cancelled, + ) + .await + } + + /// Reuse the bounded, credential-free App Server lifecycle for native skill discovery. + pub(crate) async fn skills( + executable: PathBuf, + cwd: PathBuf, + cancelled: &dyn Fn() -> bool, + ) -> Result { + let probe = Self { + executable, + cwd: cwd.clone(), + model_provider: SUBSCRIPTION_PROVIDER, + }; + probe + .exchange_cancellable( + |mut input, mut output| async move { + subscription::request( + &mut input, + &mut output, + 2, + "skills/list", + json!({"cwds": [cwd], "forceReload": true}), + ) + .await + }, + cancelled, + ) + .await + } + /// Only Codex connections have archived history; other Agents return None. pub(crate) fn for_launch(launch: &ResolvedLaunch) -> Option { let (executable, model_provider) = match &launch.subscription_cli { @@ -142,6 +217,21 @@ impl CodexHistory { where F: FnOnce(tokio::process::ChildStdin, BufReader) -> Fut, Fut: std::future::Future>, + { + self.exchange_cancellable(operation, || false).await + } + + /// Cancellation participates in the exchange so cleanup is awaited before + /// a short-lived caller drops its Tokio runtime, including during initialization. + async fn exchange_cancellable( + &self, + operation: F, + cancelled: C, + ) -> Result + where + F: FnOnce(tokio::process::ChildStdin, BufReader) -> Fut, + Fut: std::future::Future>, + C: Fn() -> bool, { let mut process = std::process::Command::new(&self.executable); // History needs no credentials; never hand API overrides to this process. @@ -167,15 +257,25 @@ impl CodexHistory { } process.kill_on_drop(true); let mut child = subscription::ProbeProcess(process.spawn().map_err(|_| "unavailable")?); - let result = async { - let mut input = child.0.stdin.take().ok_or("unavailable")?; - let mut output = BufReader::new(child.0.stdout.take().ok_or("unavailable")?); - initialize(&mut input, &mut output).await?; - operation(input, output).await + let result = { + let exchange = async { + let mut input = child.0.stdin.take().ok_or("unavailable")?; + let mut output = BufReader::new(child.0.stdout.take().ok_or("unavailable")?); + initialize(&mut input, &mut output).await?; + operation(input, output).await + }; + let bounded = tokio::time::timeout(PROBE_TIMEOUT, exchange); + tokio::pin!(bounded); + loop { + if cancelled() { + break Err("cancelled"); + } + tokio::select! { + result = &mut bounded => break result.unwrap_or(Err("timeout")), + _ = tokio::time::sleep(CANCEL_POLL_INTERVAL) => {} + } + } }; - let result = tokio::time::timeout(PROBE_TIMEOUT, result) - .await - .unwrap_or(Err("timeout")); terminate_tree(&mut child.0).await; result } diff --git a/rust/lithe-agent-host/src/instructions.rs b/rust/lithe-agent-host/src/instructions.rs new file mode 100644 index 0000000000..35d1fe18d7 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions.rs @@ -0,0 +1,324 @@ +//! Native global instruction files. The upstream Agent owns prompt loading; +//! this adapter only resolves, reads and atomically saves the user's file. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub mod shared; + +const MAX_BYTES: usize = 1024 * 1024; +static SAVE_LOCK: Mutex<()> = Mutex::new(()); + +/// Native user file snapshot; paths are display/write guards, not arbitrary inputs. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InstructionFile { + pub agent_id: String, + pub path: PathBuf, + pub content: String, + pub exists: bool, + /// Digest includes absence, so creating an empty file invalidates an old draft. + pub revision: String, +} + +/// A save must match both the selected native file and its previously read bytes. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveRequest { + pub agent_id: String, + pub content: String, + pub expected_path: PathBuf, + pub expected_revision: String, +} + +/// Stable instruction-specific reason, carried in the Core envelope's details. +#[derive(Debug)] +pub struct InstructionError { + pub reason: &'static str, + pub message: &'static str, +} + +fn error(reason: &'static str, message: &'static str) -> InstructionError { + InstructionError { reason, message } +} + +fn io_error(_: std::io::Error) -> InstructionError { + error( + "file_access", + "Cannot access the global instruction file. Check its permissions and try again.", + ) +} + +/// Resolve the same environment-based profile inherited by native Agent launches. +pub fn read(agent_id: &str) -> Result { + read_in(agent_id, &profile(agent_id)?) +} + +/// Save user text without modifying repository instructions or Agent configuration. +pub fn save(request: SaveRequest) -> Result { + let directory = profile(&request.agent_id)?; + save_in(request, &directory) +} + +// Resolution stays outside Core and never derives a destination from packaged resources. +fn profile(agent_id: &str) -> Result { + let (variable, fallback) = match agent_id { + "codex-acp" => ("CODEX_HOME", ".codex"), + "claude-acp" => ("CLAUDE_CONFIG_DIR", ".claude"), + _ => { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )) + } + }; + resolve_profile( + std::env::var_os(variable).map(PathBuf::from), + user_home().map(|home| home.join(fallback)), + ) +} + +fn user_home() -> Option { + let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(variable) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn resolve_profile( + configured: Option, + fallback: Option, +) -> Result { + let path = configured + .filter(|path| !path.as_os_str().is_empty()) + .or(fallback) + .ok_or_else(|| error("profile_missing", "Cannot locate the Agent user directory."))?; + if !path.is_absolute() { + return Err(error("profile_relative", "The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR.")); + } + Ok(path) +} + +fn read_in(agent_id: &str, directory: &Path) -> Result { + let filename = match agent_id { + "codex-acp" => { + let override_file = snapshot(agent_id, directory.join("AGENTS.override.md"))?; + if !override_file.content.trim().is_empty() { + return Ok(override_file); + } + "AGENTS.md" + } + "claude-acp" => "CLAUDE.md", + _ => { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )) + } + }; + snapshot(agent_id, directory.join(filename)) +} + +fn snapshot(agent_id: &str, path: PathBuf) -> Result { + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(error)), + }; + if metadata + .as_ref() + .is_some_and(|metadata| !metadata.file_type().is_file()) + { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + let mut bytes = Vec::new(); + if metadata.is_some() { + open_regular(&path)? + .take(MAX_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(io_error)?; + } + if bytes.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let content = String::from_utf8(bytes).map_err(|_| { + error( + "invalid_utf8", + "The global instruction file must use UTF-8 encoding.", + ) + })?; + let exists = metadata.is_some(); + let revision = format!( + "{:x}", + Sha256::digest([&[u8::from(exists)][..], content.as_bytes()].concat()) + ); + Ok(InstructionFile { + agent_id: agent_id.into(), + path, + content, + exists, + revision, + }) +} + +fn open_regular(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Do not follow a replacement symlink or block on a replacement FIFO. + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let file = options.open(path).map_err(io_error)?; + let kind = file.metadata().map_err(io_error)?.file_type(); + if !kind.is_file() || kind.is_symlink() { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + Ok(file) +} + +fn save_in(request: SaveRequest, directory: &Path) -> Result { + let _guard = SAVE_LOCK + .try_lock() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + save_unlocked(request, directory) +} + +fn save_unlocked( + request: SaveRequest, + directory: &Path, +) -> Result { + if request.content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let previous = read_in(&request.agent_id, directory)?; + check_revision(&previous, &request)?; + write_snapshot(&previous, &request.content, || { + read_in(&request.agent_id, directory) + })?; + // Clearing an override can make AGENTS.md effective again. Return that actual source. + read_in(&request.agent_id, directory) +} + +// Both the shared library and native files use the same guarded atomic replacement. +// The caller holds SAVE_LOCK; the recheck also protects native precedence changes. +fn write_snapshot( + previous: &InstructionFile, + content: &str, + recheck: impl Fn() -> Result, +) -> Result<(), InstructionError> { + if content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let directory = previous + .path + .parent() + .ok_or_else(|| io_error(std::io::Error::other("missing parent")))?; + let permissions = if previous.exists { + let permissions = fs::metadata(&previous.path) + .map_err(io_error)? + .permissions(); + if permissions.readonly() { + return Err(error( + "file_access", + "Cannot access the global instruction file. Check its permissions and try again.", + )); + } + Some(permissions) + } else { + None + }; + let mut directories = fs::DirBuilder::new(); + directories.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directories.mode(0o700); + } + directories.create(directory).map_err(io_error)?; + let parent = fs::canonicalize(directory).map_err(io_error)?; + let stage_path = parent.join(format!(".lithe-instructions-{}.tmp", uuid::Uuid::new_v4())); + let stage = StagedFile(stage_path.clone()); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&stage_path).map_err(io_error)?; + file.write_all(content.as_bytes()).map_err(io_error)?; + if let Some(permissions) = permissions { + file.set_permissions(permissions).map_err(io_error)?; + } + file.sync_all().map_err(io_error)?; + drop(file); + // Detect edits/profile precedence changes made while preparing the write. + // Other applications do not share our lock, so this is an optimistic guard. + check_revision( + &recheck()?, + &SaveRequest { + agent_id: previous.agent_id.clone(), + content: String::new(), + expected_path: previous.path.clone(), + expected_revision: previous.revision.clone(), + }, + )?; + if fs::canonicalize(directory).map_err(io_error)? != parent { + return Err(error( + "file_changed", + "The instruction directory changed. Reload before saving.", + )); + } + let target = parent.join( + previous + .path + .file_name() + .expect("native instruction filename"), + ); + fs::rename(&stage.0, target).map_err(io_error)?; + Ok(()) +} + +fn check_revision(file: &InstructionFile, request: &SaveRequest) -> Result<(), InstructionError> { + if file.path != request.expected_path || file.revision != request.expected_revision { + return Err(error("file_changed", "Global instructions changed outside this editor. Your draft is kept; reload before saving.")); + } + Ok(()) +} + +struct StagedFile(PathBuf); +impl Drop for StagedFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/instructions/shared.rs b/rust/lithe-agent-host/src/instructions/shared.rs new file mode 100644 index 0000000000..40c6481404 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/shared.rs @@ -0,0 +1,416 @@ +//! User-owned shared text and guarded, per-Agent synchronization of a marked block. +//! The platform supplies its writable application-support directory; opening the +//! page is read-only. No prompt injection, package writes or background sync occurs. + +use super::*; +use fs2::FileExt; + +const BEGIN: &str = ""; +const END: &str = ""; +const AGENTS: [&str; 2] = ["codex-acp", "claude-acp"]; + +/// Platform-selected storage plus an explicit read, text save or Agent switch. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub data_directory: PathBuf, + #[serde(flatten)] + pub action: Action, +} + +/// Writes always name the source revision and the native snapshots being changed. +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +pub enum Action { + Read {}, + Save { + content: String, + #[serde(rename = "expectedRevision")] + expected_revision: String, + files: Vec, + }, + SetEnabled { + #[serde(rename = "agentId")] + agent_id: String, + enabled: bool, + #[serde(rename = "expectedRevision")] + expected_revision: String, + file: FileGuard, + }, +} + +/// A native path is only a snapshot guard; destinations are resolved by the host. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FileGuard { + pub agent_id: String, + pub path: PathBuf, + pub revision: String, +} + +/// Enabled state comes from actual file bytes, never from a persisted switch value. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentState { + pub agent_id: String, + pub file: Option, + pub enabled: Option, + pub needs_sync: bool, + pub own_line_count: usize, + pub issue: Option, +} + +/// A text save can succeed while one Agent fails; every failure stays visible. +#[derive(Serialize)] +pub struct Library { + pub file: InstructionFile, + pub agents: Vec, +} + +/// Execute a bounded operation. Cancellation is observed between native writes; +/// completed file replacements remain committed and are reported by the next read. +pub fn execute( + request: Request, + cancelled: &impl Fn() -> bool, +) -> Result { + if !request.data_directory.is_absolute() { + return Err(error( + "profile_relative", + "The shared instruction directory must be absolute.", + )); + } + execute_in(request, &profile, cancelled, &SAVE_LOCK) +} + +fn execute_in( + request: Request, + resolve: &impl Fn(&str) -> Result, + cancelled: &impl Fn() -> bool, + write_lock: &Mutex<()>, +) -> Result { + let path = request.data_directory.join("Shared.md"); + if matches!(request.action, Action::Read {}) { + return library(snapshot("shared", path)?, resolve); + } + let _guard = write_lock + .try_lock() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + let before = snapshot("shared", path.clone())?; + let expected = match &request.action { + Action::Save { + expected_revision, .. + } + | Action::SetEnabled { + expected_revision, .. + } => expected_revision, + Action::Read {} => unreachable!(), + }; + if &before.revision != expected { + return Err(error( + "file_changed", + "Shared instructions changed outside this editor. Reload before saving.", + )); + } + // One nonblocking OS lock protects separate Lithe processes sharing this library. + // Its persistent empty file is user state, never a transferable build resource. + match &request.action { + Action::Save { content, files, .. } => { + validate_text(content)?; + let ids: std::collections::HashSet<_> = + files.iter().map(|file| file.agent_id.as_str()).collect(); + if files.len() > AGENTS.len() + || ids.len() != files.len() + || ids.iter().any(|id| !AGENTS.contains(id)) + { + return Err(error( + "invalid_request", + "Invalid shared instruction request.", + )); + } + } + Action::SetEnabled { agent_id, file, .. } + if !AGENTS.contains(&agent_id.as_str()) || file.agent_id != *agent_id => + { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )); + } + _ => {} + } + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let mut directories = fs::DirBuilder::new(); + directories.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directories.mode(0o700); + } + directories + .create(&request.data_directory) + .map_err(io_error)?; + let lock_path = request.data_directory.join(".sync.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let lock = options.open(lock_path).map_err(io_error)?; + let kind = lock.metadata().map_err(io_error)?.file_type(); + if !kind.is_file() || kind.is_symlink() { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + lock.try_lock_exclusive() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + if snapshot("shared", path.clone())?.revision != before.revision { + return Err(error( + "file_changed", + "Shared instructions changed outside this editor. Reload before saving.", + )); + } + let mut failures = Vec::new(); + match request.action { + Action::Save { content, files, .. } => { + validate_text(&content)?; + if files.len() > AGENTS.len() { + return Err(error( + "invalid_request", + "Invalid shared instruction request.", + )); + } + write_snapshot(&before, &content, || snapshot("shared", path.clone()))?; + // Commit the source first. Failed targets retain their old block and + // expose needsSync; retrying is explicit and does not lose user text. + for agent in AGENTS { + let result = (|| { + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let directory = resolve(agent)?; + let current = read_in(agent, &directory)?; + if block(¤t.content)?.is_none() { + return Ok(()); + } + let guard = files + .iter() + .find(|file| file.agent_id == agent) + .ok_or_else(|| { + error( + "file_changed", + "Reload this Agent before synchronizing instructions.", + ) + })?; + sync(¤t, guard, Some(&content), &directory) + })(); + if let Err(err) = result { + failures.push((agent.to_string(), err.message.to_string())); + } + } + } + Action::SetEnabled { + agent_id, + enabled, + file, + .. + } => { + if !AGENTS.contains(&agent_id.as_str()) || file.agent_id != agent_id { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )); + } + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let directory = resolve(&agent_id)?; + let current = read_in(&agent_id, &directory)?; + sync( + ¤t, + &file, + enabled.then_some(before.content.as_str()), + &directory, + )?; + } + Action::Read {} => unreachable!(), + } + let mut result = library(snapshot("shared", path)?, resolve)?; + for (agent, message) in failures { + if let Some(state) = result + .agents + .iter_mut() + .find(|state| state.agent_id == agent) + { + state.issue = Some(message); + } + } + Ok(result) +} + +fn library( + file: InstructionFile, + resolve: &impl Fn(&str) -> Result, +) -> Result { + let agents = AGENTS + .iter() + .map(|agent| { + let result = resolve(agent).and_then(|directory| read_in(agent, &directory)); + match result { + Ok(native) => match block(&native.content) { + Ok(managed) => { + let own = without_block(&native.content, managed.as_ref()); + AgentState { + agent_id: (*agent).into(), + enabled: Some(managed.is_some()), + needs_sync: managed + .as_ref() + .is_some_and(|block| block.text != file.content), + own_line_count: own + .lines() + .filter(|line| !line.trim().is_empty()) + .count(), + file: Some(native), + issue: None, + } + } + Err(err) => AgentState { + agent_id: (*agent).into(), + file: Some(native), + enabled: None, + needs_sync: false, + own_line_count: 0, + issue: Some(err.message.into()), + }, + }, + Err(err) => AgentState { + agent_id: (*agent).into(), + file: None, + enabled: None, + needs_sync: false, + own_line_count: 0, + issue: Some(err.message.into()), + }, + } + }) + .collect(); + Ok(Library { file, agents }) +} + +fn sync( + current: &InstructionFile, + guard: &FileGuard, + text: Option<&str>, + directory: &Path, +) -> Result<(), InstructionError> { + let request = SaveRequest { + agent_id: current.agent_id.clone(), + content: String::new(), + expected_path: guard.path.clone(), + expected_revision: guard.revision.clone(), + }; + check_revision(current, &request)?; + let managed = block(¤t.content)?; + if let Some(text) = text { + validate_text(text)?; + } + let replacement = text + .map(|text| format!("\n{BEGIN}\n{text}\n{END}\n")) + .unwrap_or_default(); + let content = if let Some(managed) = managed { + format!( + "{}{}{}", + ¤t.content[..managed.start], + replacement, + ¤t.content[managed.end..] + ) + } else { + format!("{}{}", current.content, replacement) + }; + if content == current.content { + return Ok(()); + } + save_unlocked(SaveRequest { content, ..request }, directory)?; + Ok(()) +} + +struct Block<'a> { + start: usize, + end: usize, + text: &'a str, +} + +fn block(content: &str) -> Result>, InstructionError> { + let opens = content.matches(BEGIN).count(); + let closes = content.matches(END).count(); + if opens == 0 && closes == 0 { + return Ok(None); + } + let invalid = || { + error("invalid_markers", "The Lithe shared instruction markers were changed. Repair the native file before syncing.") + }; + if opens != 1 || closes != 1 { + return Err(invalid()); + } + // Include our own leading/trailing newline in the range. Removing a block + // restores the exact original bytes, even when the user file had no newline. + let open = format!("\n{BEGIN}\n"); + let close = format!("\n{END}\n"); + let start = content.find(&open).ok_or_else(invalid)?; + let text_start = start + open.len(); + let close_start = content[text_start..] + .find(&close) + .map(|index| text_start + index) + .ok_or_else(invalid)?; + Ok(Some(Block { + start, + end: close_start + close.len(), + text: &content[text_start..close_start], + })) +} + +fn without_block(content: &str, managed: Option<&Block<'_>>) -> String { + managed + .map(|block| format!("{}{}", &content[..block.start], &content[block.end..])) + .unwrap_or_else(|| content.into()) +} + +fn validate_text(content: &str) -> Result<(), InstructionError> { + if content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + if content.contains(BEGIN) || content.contains(END) { + return Err(error( + "invalid_markers", + "Shared instructions cannot contain Lithe synchronization markers.", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/instructions/shared/tests.rs b/rust/lithe-agent-host/src/instructions/shared/tests.rs new file mode 100644 index 0000000000..4939fc717c --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/shared/tests.rs @@ -0,0 +1,228 @@ +use super::*; + +struct Fixture { + path: PathBuf, + write_lock: Mutex<()>, +} +impl Fixture { + fn new() -> Self { + Self { + path: std::env::temp_dir().join(format!("lithe-shared-{}", uuid::Uuid::new_v4())), + write_lock: Mutex::new(()), + } + } + fn profile(&self, agent: &str) -> Result { + Ok(self.path.join(agent)) + } + fn read(&self) -> Library { + self.run(Action::Read {}).unwrap() + } + fn run(&self, action: Action) -> Result { + execute_in( + Request { + data_directory: self.path.join("library"), + action, + }, + &|agent| self.profile(agent), + &|| false, + &self.write_lock, + ) + } + fn save(&self, library: &Library, text: &str) -> Library { + self.run(Action::Save { + content: text.into(), + expected_revision: library.file.revision.clone(), + files: library + .agents + .iter() + .filter_map(|agent| agent.file.as_ref().map(guard)) + .collect(), + }) + .unwrap() + } + fn toggle( + &self, + library: &Library, + agent: &str, + enabled: bool, + ) -> Result { + self.run(Action::SetEnabled { + agent_id: agent.into(), + enabled, + expected_revision: library.file.revision.clone(), + file: guard( + library + .agents + .iter() + .find(|row| row.agent_id == agent) + .unwrap() + .file + .as_ref() + .unwrap(), + ), + }) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} +fn guard(file: &InstructionFile) -> FileGuard { + FileGuard { + agent_id: file.agent_id.clone(), + path: file.path.clone(), + revision: file.revision.clone(), + } +} +fn enabled(library: &Library, agent: &str) -> bool { + library + .agents + .iter() + .find(|row| row.agent_id == agent) + .unwrap() + .enabled + == Some(true) +} + +#[test] +fn shared_sync_preserves_own_bytes_checks_revisions_and_reports_partial_failures() { + let fixture = Fixture::new(); + let first = fixture.read(); + assert!( + !fixture.path.exists(), + "opening settings must not create any files" + ); + let locked = fixture.write_lock.try_lock().unwrap(); + assert_eq!( + fixture + .run(Action::Save { + content: "not written".into(), + expected_revision: first.file.revision.clone(), + files: vec![] + }) + .err() + .unwrap() + .reason, + "busy" + ); + assert!(!fixture.path.exists()); + drop(locked); + let codex = fixture.profile("codex-acp").unwrap(); + let claude = fixture.profile("claude-acp").unwrap(); + fs::create_dir_all(&codex).unwrap(); + fs::create_dir_all(&claude).unwrap(); + let own_codex = "# 私有规则\r\n无结尾换行"; + fs::write(codex.join("AGENTS.md"), own_codex).unwrap(); + fs::write(claude.join("CLAUDE.md"), "Claude only\n").unwrap(); + let library = fixture.save(&first, "共享中文\nKeep identifiers."); + assert!(!enabled(&library, "codex-acp")); + let library = fixture.toggle(&library, "codex-acp", true).unwrap(); + let library = fixture.toggle(&library, "claude-acp", true).unwrap(); + assert!(enabled(&library, "codex-acp") && enabled(&library, "claude-acp")); + let library = fixture.save(&library, "第二版"); + assert!(library + .agents + .iter() + .all(|row| !row.needs_sync && row.issue.is_none())); + assert!(fs::read_to_string(codex.join("AGENTS.md")) + .unwrap() + .starts_with(own_codex)); + let stale = library; + fs::write( + codex.join("AGENTS.md"), + format!( + "外部修改{}", + fs::read_to_string(codex.join("AGENTS.md")).unwrap() + ), + ) + .unwrap(); + let library = fixture.save(&stale, "第三版"); + let row = &library.agents[0]; + assert!(row.needs_sync && row.issue.is_some()); + assert!( + !library.agents[1].needs_sync, + "another Agent can complete despite one conflict" + ); + assert_eq!( + fixture + .toggle(&stale, "codex-acp", false) + .err() + .unwrap() + .reason, + "file_changed" + ); + let library = fixture.save(&library, "第三版"); + assert!(library.agents.iter().all(|row| !row.needs_sync)); + let library = fixture.toggle(&library, "codex-acp", false).unwrap(); + assert!(!enabled(&library, "codex-acp") && enabled(&library, "claude-acp")); + assert_eq!( + fs::read_to_string(codex.join("AGENTS.md")).unwrap(), + format!("外部修改{own_codex}") + ); + let _ = fixture.toggle(&library, "claude-acp", false).unwrap(); + assert_eq!( + fs::read_to_string(claude.join("CLAUDE.md")).unwrap(), + "Claude only\n" + ); + // Reopening uses disk snapshots; no in-memory preference can claim a stale switch. + let reopened = fixture.read(); + assert!(reopened.agents.iter().all(|row| row.enabled == Some(false))); + assert_eq!(reopened.file.content, "第三版"); + fs::write(codex.join("AGENTS.override.md"), "override text").unwrap(); + let library = fixture.read(); + let library = fixture.toggle(&library, "codex-acp", true).unwrap(); + assert!(library.agents[0] + .file + .as_ref() + .unwrap() + .path + .ends_with("AGENTS.override.md")); + let _ = fixture.toggle(&library, "codex-acp", false).unwrap(); + assert_eq!( + fs::read_to_string(codex.join("AGENTS.override.md")).unwrap(), + "override text" + ); + // Malformed or duplicate markers are left untouched and the switch becomes unknown. + fs::write(claude.join("CLAUDE.md"), BEGIN).unwrap(); + let library = fixture.read(); + assert!(library.agents[1].enabled.is_none() && library.agents[1].issue.is_some()); + assert_eq!( + fixture + .toggle(&library, "claude-acp", true) + .err() + .unwrap() + .reason, + "invalid_markers" + ); + assert_eq!(fs::read_to_string(claude.join("CLAUDE.md")).unwrap(), BEGIN); +} + +#[test] +fn marked_ranges_roundtrip_and_reject_ambiguous_boundaries() { + for own in ["", "no newline", "ends in LF\n", "CRLF\r\n", "中文\n\n"] { + for shared in ["", "中文", "line\n", "line\r\n"] { + let combined = format!("{own}\n{BEGIN}\n{shared}\n{END}\nextra"); + let managed = block(&combined).unwrap().unwrap(); + assert_eq!(managed.text, shared); + assert_eq!( + without_block(&combined, Some(&managed)), + format!("{own}extra") + ); + } + } + for malformed in [ + BEGIN.to_string(), + END.to_string(), + format!("\n{BEGIN}\n{BEGIN}\nx\n{END}\n"), + format!("{BEGIN}\nx\n{END}"), + ] { + assert!(block(&malformed).is_err()); + } + assert!(validate_text(BEGIN).is_err()); + assert!(validate_text(&"x".repeat(MAX_BYTES + 1)).is_err()); + let request: Request = + serde_json::from_value(serde_json::json!({"dataDirectory":"/fixture", "action":"read"})) + .unwrap(); + assert!(matches!(request.action, Action::Read {})); +} diff --git a/rust/lithe-agent-host/src/instructions/tests.rs b/rust/lithe-agent-host/src/instructions/tests.rs new file mode 100644 index 0000000000..dc8b00b562 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/tests.rs @@ -0,0 +1,203 @@ +use super::*; + +struct Directory(PathBuf); +impl Directory { + fn new() -> Self { + Self(std::env::temp_dir().join(format!("lithe-instructions-{}", uuid::Uuid::new_v4()))) + } + fn write(&self, name: &str, bytes: &[u8]) { + fs::create_dir_all(&self.0).unwrap(); + fs::write(self.0.join(name), bytes).unwrap(); + } +} +impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn request(file: &InstructionFile, content: &str) -> SaveRequest { + SaveRequest { + agent_id: file.agent_id.clone(), + content: content.into(), + expected_path: file.path.clone(), + expected_revision: file.revision.clone(), + } +} + +#[test] +fn native_sources_follow_override_and_agent_boundaries_without_creating_files() { + let directory = Directory::new(); + let empty = read_in("codex-acp", &directory.0).unwrap(); + assert!(!empty.exists); + assert!(!directory.0.exists(), "opening settings is read only"); + directory.write("AGENTS.md", "base 中文\n".as_bytes()); + directory.write("CLAUDE.md", b"claude"); + directory.write("AGENTS.override.md", b" \n"); + assert_eq!( + read_in("codex-acp", &directory.0).unwrap().content, + "base 中文\n" + ); + directory.write("AGENTS.override.md", b"override"); + assert_eq!( + read_in("codex-acp", &directory.0).unwrap().content, + "override" + ); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap().content, + "claude" + ); + assert_eq!( + read_in("custom", &directory.0).unwrap_err().reason, + "unsupported_agent" + ); +} + +#[test] +fn save_roundtrip_guards_external_edits_and_native_precedence() { + // All saves in this case share the native save lock; no environment mutation. + let directory = Directory::new(); + let missing = read_in("codex-acp", &directory.0).unwrap(); + let saved = save_in(request(&missing, "中文\r\nexact bytes\n"), &directory.0).unwrap(); + assert_eq!(saved.content, "中文\r\nexact bytes\n"); + assert!(saved.exists); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&saved.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&directory.0).unwrap().permissions().mode() & 0o777, + 0o700 + ); + fs::set_permissions(&saved.path, fs::Permissions::from_mode(0o640)).unwrap(); + } + let updated = save_in(request(&saved, "base"), &directory.0).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&updated.path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + } + let original_permissions = fs::metadata(&updated.path).unwrap().permissions(); + let mut readonly = original_permissions.clone(); + readonly.set_readonly(true); + fs::set_permissions(&updated.path, readonly).unwrap(); + assert_eq!( + save_in(request(&updated, "read only overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_access" + ); + assert_eq!(fs::read_to_string(&updated.path).unwrap(), "base"); + fs::set_permissions(&updated.path, original_permissions).unwrap(); + directory.write("AGENTS.md", b"external edit"); + assert_eq!( + save_in(request(&updated, "overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_changed" + ); + assert_eq!(fs::read_to_string(&updated.path).unwrap(), "external edit"); + directory.write("AGENTS.override.md", b"override"); + assert_eq!( + save_in(request(&updated, "overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_changed" + ); + let active = read_in("codex-acp", &directory.0).unwrap(); + let effective = save_in(request(&active, ""), &directory.0).unwrap(); + assert!(effective.path.ends_with("AGENTS.md")); + assert_eq!(effective.content, "external edit"); + let cleared = save_in(request(&effective, ""), &directory.0).unwrap(); + assert_eq!(cleared.content, ""); + let claude = read_in("claude-acp", &directory.0).unwrap(); + let claude = save_in(request(&claude, "Claude only"), &directory.0).unwrap(); + assert_eq!(claude.content, "Claude only"); + assert_eq!(read_in("codex-acp", &directory.0).unwrap().content, ""); + let mut wrong_path = request(&claude, "bad"); + wrong_path.expected_path = directory.0.join("arbitrary.md"); + assert_eq!( + save_in(wrong_path, &directory.0).unwrap_err().reason, + "file_changed" + ); + let oversized = "x".repeat(MAX_BYTES + 1); + assert_eq!( + save_in(request(&claude, &oversized), &directory.0) + .unwrap_err() + .reason, + "file_too_large" + ); + assert!(!fs::read_dir(&directory.0).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".lithe-"))); +} + +#[test] +fn profile_resolution_respects_explicit_configuration_and_rejects_relative_paths() { + let directory = Directory::new(); + assert_eq!( + resolve_profile(Some(directory.0.clone()), None).unwrap(), + directory.0 + ); + assert_eq!( + resolve_profile(None, Some(directory.0.clone())).unwrap(), + directory.0 + ); + assert_eq!( + resolve_profile(Some(PathBuf::from("relative")), Some(directory.0.clone())) + .unwrap_err() + .reason, + "profile_relative" + ); + assert_eq!( + resolve_profile(None, None).unwrap_err().reason, + "profile_missing" + ); +} + +#[test] +fn rejects_non_text_and_non_regular_files_and_distinguishes_absence() { + let directory = Directory::new(); + let absent = read_in("claude-acp", &directory.0).unwrap(); + directory.write("CLAUDE.md", b""); + assert_ne!( + read_in("claude-acp", &directory.0).unwrap().revision, + absent.revision + ); + directory.write("CLAUDE.md", &[0xff]); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "invalid_utf8" + ); + directory.write("CLAUDE.md", &vec![b'x'; MAX_BYTES + 1]); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "file_too_large" + ); + fs::remove_file(directory.0.join("CLAUDE.md")).unwrap(); + fs::create_dir(directory.0.join("CLAUDE.md")).unwrap(); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "unsafe_file" + ); + #[cfg(unix)] + { + fs::remove_dir(directory.0.join("CLAUDE.md")).unwrap(); + directory.write("target.md", b"keep"); + std::os::unix::fs::symlink(directory.0.join("target.md"), directory.0.join("CLAUDE.md")) + .unwrap(); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "unsafe_file" + ); + assert_eq!(fs::read(directory.0.join("target.md")).unwrap(), b"keep"); + } +} diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index 6ac9c20d15..bc94fb2b17 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -13,6 +13,8 @@ mod codex_history; mod codex_retry; pub mod environment; pub mod install; +pub mod instructions; +pub mod mcp_probe; mod model_calls; mod model_proxy; mod prompt; @@ -20,6 +22,7 @@ mod prompt_retry; mod session_defaults; mod session_restore; mod session_routing; +pub mod skills; mod steering; mod subagents; mod subscription; diff --git a/rust/lithe-agent-host/src/mcp_probe.rs b/rust/lithe-agent-host/src/mcp_probe.rs new file mode 100644 index 0000000000..8652534e98 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe.rs @@ -0,0 +1,284 @@ +//! Explicit, short-lived MCP inventory probes using the official MCP SDK. +//! +//! Probes list metadata only. They never call tools, persist credentials or +//! start automatically while the settings page is opening. + +use rmcp::{model::PaginatedRequestParams, ServiceExt}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{collections::HashSet, process::Stdio, time::Duration}; +use tokio::{io::AsyncReadExt, process::Command}; +mod http_client; + +/// User-reviewed connection, supplied by a platform's native configuration store. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Request { + /// `stdio`, `http`, or the legacy `sse` configuration type. + pub transport: String, + /// Executable, without shell evaluation. + pub command: Option, + /// Exact argument boundaries, including empty and space-containing values. + #[serde(default)] + pub args: Vec, + /// Environment overrides for this child only. + #[serde(default)] + pub env: Vec, + /// Remote endpoint; HTTPS or loopback HTTP only. + pub url: Option, + /// Explicit request headers. Values are never returned in errors. + #[serde(default)] + pub headers: Vec, +} + +/// One environment or HTTP header entry; never logged. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + /// Environment or header name. + pub key: String, + /// User-supplied value; never included in diagnostics. + pub value: String, +} + +/// Stable diagnostic category, deliberately independent of upstream error text. +#[derive(Debug, PartialEq)] +pub enum Error { + Invalid, + Unsupported, + Start, + Connection, + Limit, + Timeout, + Cancelled, +} + +const DEADLINE: Duration = Duration::from_secs(30); +const MAX_BYTES: u64 = 2 * 1024 * 1024; +const MAX_TOOLS: usize = 1000; + +/// Runs one bounded probe on the calling worker thread. The caller owns cancel. +pub fn execute(mut request: Request, cancelled: &dyn Fn() -> bool) -> Result { + validate(&request)?; + if cancelled() { + return Err(Error::Cancelled); + } + if request.transport == "stdio" && !request.env.iter().any(|e| e.key == "PATH") { + if let Some(path) = crate::environment::search_path_with_cancel(cancelled) { + request.env.push(Entry { + key: "PATH".into(), + value: path.to_string_lossy().into_owned(), + }); + } + } + if cancelled() { + return Err(Error::Cancelled); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| Error::Start)?; + runtime.block_on(run(request, cancelled)) +} + +fn validate(r: &Request) -> Result<(), Error> { + let bytes = r.args.iter().map(String::len).sum::() + + r.env + .iter() + .chain(&r.headers) + .map(|e| e.key.len() + e.value.len()) + .sum::(); + if bytes > 1024 * 1024 || r.args.len() > 256 || r.env.len() > 128 || r.headers.len() > 128 { + return Err(Error::Invalid); + } + match r.transport.as_str() { + "stdio" + if r.command + .as_ref() + .is_some_and(|s| !s.is_empty() && !s.contains('\0')) => + { + Ok(()) + } + "http" => { + let u = url::Url::parse(r.url.as_deref().unwrap_or("")).map_err(|_| Error::Invalid)?; + let loopback = u.host_str().is_some_and(|s| { + s == "localhost" || s == "127.0.0.1" || s == "[::1]" || s == "::1" + }); + if (u.scheme() != "https" && !(u.scheme() == "http" && loopback)) + || u.host_str().is_none() + || !u.username().is_empty() + || u.password().is_some() + || u.fragment().is_some() + { + return Err(Error::Invalid); + } + Ok(()) + } + "sse" => Err(Error::Unsupported), + _ => Err(Error::Invalid), + } +} + +async fn run(r: Request, cancelled: &dyn Fn() -> bool) -> Result { + let mut child = None; + let transport = r.transport.clone(); + let mut command = if transport == "stdio" { + let path = r + .env + .iter() + .find(|e| e.key == "PATH") + .map(|e| std::ffi::OsStr::new(&e.value)); + let executable = + crate::environment::find_executable(r.command.as_deref().ok_or(Error::Invalid)?, path) + .ok_or(Error::Start)?; + let mut cmd = Command::new(executable); + cmd.args(&r.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + for e in &r.env { + cmd.env(&e.key, &e.value); + } + #[cfg(unix)] + cmd.process_group(0); + let mut spawned = cmd.spawn().map_err(|_| Error::Start)?; + let output = spawned.stdout.take().ok_or(Error::Start)?; + let input = spawned.stdin.take().ok_or(Error::Start)?; + child = Some(spawned); + // Bound the complete stdio metadata exchange, including an unterminated + // JSON line; SDK parsing cannot grow forever on a malicious child. + Some((output.take(MAX_BYTES), input)) + } else { + None + }; + let result = async { + if let Some(streams) = command.take() { + inventory(streams, cancelled).await + } else { + use rmcp::transport::streamable_http_client::{ + StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + }; + let mut headers = http::HeaderMap::new(); + for e in &r.headers { + let key = http::header::HeaderName::from_bytes(e.key.as_bytes()) + .map_err(|_| Error::Invalid)?; + // Session and content framing belong to the SDK, not user headers. + if matches!( + key.as_str(), + "host" + | "content-length" + | "content-type" + | "accept" + | "mcp-session-id" + | "mcp-protocol-version" + ) { + return Err(Error::Invalid); + } + let mut value = + http::header::HeaderValue::from_str(&e.value).map_err(|_| Error::Invalid)?; + value.set_sensitive(true); + headers.insert(key, value); + } + let client = reqwest::Client::builder() + .default_headers(headers) + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(8)) + .timeout(DEADLINE) + .build() + .map_err(|_| Error::Start)?; + let mut config = + StreamableHttpClientTransportConfig::with_uri(r.url.ok_or(Error::Invalid)?); + config.max_sse_event_size = MAX_BYTES as usize; + let http = StreamableHttpClientTransport::with_client( + http_client::BoundedClient(client), + config, + ); + inventory(http, cancelled).await + } + } + .await; + if let Some(mut child) = child { + if let Some(pid) = child.id() { + crate::force_kill_tree(pid); + } + let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await; + } + result +} + +#[cfg(test)] +mod tests; + +async fn inventory(transport: T, cancelled: &dyn Fn() -> bool) -> Result +where + T: rmcp::transport::IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + let deadline = tokio::time::Instant::now() + DEADLINE; + let cancel = async { + let mut ticks = tokio::time::interval(Duration::from_millis(50)); + loop { + ticks.tick().await; + if cancelled() { + return; + } + } + }; + tokio::pin!(cancel); + let service = tokio::select! { + _ = &mut cancel => return Err(Error::Cancelled), + _ = tokio::time::sleep_until(deadline) => return Err(Error::Timeout), + value = ().serve(transport) => value.map_err(|_| Error::Connection)?, + }; + let listing = async { + let mut tools = Vec::new(); + let mut names = HashSet::new(); + let mut cursors = HashSet::new(); + let mut cursor = None; + let mut size = 0; + for _ in 0..20 { + let mut params = PaginatedRequestParams::default(); + params.cursor = cursor; + let page = service + .list_tools(Some(params)) + .await + .map_err(|_| Error::Connection)?; + for tool in page.tools { + let name = tool.name.to_string(); + let description = tool.description.unwrap_or_default().to_string(); + size += name.len() + description.len(); + if name.is_empty() + || name.len() > 256 + || description.len() > 16384 + || size > MAX_BYTES as usize + || tools.len() >= MAX_TOOLS + { + return Err(Error::Limit); + } + if names.insert(name.clone()) { + tools.push(json!({"name": name, "description": description})); + } + } + cursor = page.next_cursor; + if cursor.is_none() { + tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + return Ok(json!({"tools": tools})); + } + if cursor.as_ref().is_some_and(|c| c.len() > 2048) { + return Err(Error::Limit); + } + if !cursors.insert(cursor.clone()) { + return Err(Error::Limit); + } + } + Err(Error::Limit) + }; + let result = tokio::select! { + _ = &mut cancel => Err(Error::Cancelled), + _ = tokio::time::sleep_until(deadline) => Err(Error::Timeout), + value = listing => value, + }; + let _ = tokio::time::timeout(Duration::from_secs(2), service.cancel()).await; + result +} diff --git a/rust/lithe-agent-host/src/mcp_probe/http_client.rs b/rust/lithe-agent-host/src/mcp_probe/http_client.rs new file mode 100644 index 0000000000..8fd6bf35d8 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe/http_client.rs @@ -0,0 +1,174 @@ +//! Bounded HTTP byte adapter for the official SDK's session/SSE transport. +//! Keeps the existing reqwest/TLS stack and caps JSON, errors and SSE bodies +//! before decoding; upstream's default JSON adapter reads an unbounded body. + +use super::MAX_BYTES; +use futures::{stream::BoxStream, StreamExt}; +use http::{HeaderName, HeaderValue}; +use rmcp::{ + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::{Error as SseError, Sse, SseStream}; +use std::{collections::HashMap, io, sync::Arc}; + +#[derive(Clone)] +pub(super) struct BoundedClient(pub reqwest::Client); +type Error = StreamableHttpError; +fn failed() -> Error { + StreamableHttpError::Client(io::Error::other("MCP HTTP request failed")) +} + +fn stream(response: reqwest::Response) -> BoxStream<'static, Result> { + response + .bytes_stream() + .scan(0_u64, |size, chunk| { + let value = chunk.map_err(|_| io::Error::other("MCP response failed")); + if let Ok(bytes) = &value { + *size += bytes.len() as u64; + } + let value = if *size > MAX_BYTES { + Err(io::Error::other("MCP response limit")) + } else { + value + }; + futures::future::ready(Some(value)) + }) + .boxed() +} + +fn request( + client: &reqwest::Client, + method: reqwest::Method, + uri: &str, + session: Option>, + auth: Option, + headers: HashMap, +) -> reqwest::RequestBuilder { + let mut r = client + .request(method, uri) + .header("accept", "application/json, text/event-stream"); + if let Some(session) = session { + r = r.header("mcp-session-id", session.as_ref()); + } + if let Some(auth) = auth { + r = r.bearer_auth(auth); + } + for (key, value) in headers { + r = r.header(key, value); + } + r +} + +impl StreamableHttpClient for BoundedClient { + type Error = io::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session: Option>, + auth: Option, + headers: HashMap, + ) -> Result { + let encoded = serde_json::to_vec(&message).map_err(|_| failed())?; + let response = request( + &self.0, + reqwest::Method::POST, + &uri, + session.clone(), + auth, + headers, + ) + .header("content-type", "application/json") + .body(encoded) + .send() + .await + .map_err(|_| failed())?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND && session.is_some() { + return Err(StreamableHttpError::SessionExpired); + } + if matches!(status.as_u16(), 202 | 204) { + return Ok(StreamableHttpPostResponse::Accepted); + } + let session = response + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let sse = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| s.starts_with("text/event-stream")); + if response + .content_length() + .is_some_and(|size| size > MAX_BYTES) + { + return Err(failed()); + } + if status.is_success() && sse { + return Ok(StreamableHttpPostResponse::Sse( + SseStream::from_bytes_stream(stream(response)).boxed(), + session, + )); + } + let mut bytes = Vec::new(); + let mut input = stream(response); + while let Some(chunk) = input.next().await { + bytes.extend_from_slice(&chunk.map_err(|_| failed())?); + } + if bytes.is_empty() && status.is_success() { + return Ok(StreamableHttpPostResponse::Accepted); + } + // Keep valid JSON-RPC errors for SDK negotiation/fallback, but never + // return an arbitrary HTML/auth body in a user-visible diagnostic. + let parsed: ServerJsonRpcMessage = serde_json::from_slice(&bytes).map_err(|_| failed())?; + Ok(StreamableHttpPostResponse::Json(parsed, session)) + } + + async fn delete_session( + &self, + uri: Arc, + session: Arc, + auth: Option, + headers: HashMap, + ) -> Result<(), Error> { + request( + &self.0, + reqwest::Method::DELETE, + &uri, + Some(session), + auth, + headers, + ) + .send() + .await + .map_err(|_| failed())?; + Ok(()) + } + + async fn get_stream( + &self, + uri: Arc, + session: Option>, + last: Option, + auth: Option, + headers: HashMap, + ) -> Result>, Error> { + let mut request = request(&self.0, reqwest::Method::GET, &uri, session, auth, headers); + if let Some(last) = last { + request = request.header("last-event-id", last); + } + let response = request + .send() + .await + .map_err(|_| failed())? + .error_for_status() + .map_err(|_| failed())?; + Ok(SseStream::from_bytes_stream(stream(response)).boxed()) + } +} diff --git a/rust/lithe-agent-host/src/mcp_probe/tests.rs b/rust/lithe-agent-host/src/mcp_probe/tests.rs new file mode 100644 index 0000000000..5870359ab2 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe/tests.rs @@ -0,0 +1,236 @@ +//! Local transport fixtures never call a public server or an installed runtime. +use super::*; +use std::io::{BufRead, Write}; + +fn fixture_request() -> Request { + Request { + transport: "stdio".into(), + command: Some( + std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(), + ), + args: vec![ + "--exact".into(), + "mcp_probe::tests::fixture_server".into(), + "--nocapture".into(), + ], + env: vec![ + Entry { + key: "LITHE_MCP_PROBE_FIXTURE".into(), + value: "1".into(), + }, + Entry { + key: "PATH".into(), + value: std::env::var("PATH").unwrap_or_default(), + }, + ], + url: None, + headers: vec![], + } +} + +fn response(message: &Value) -> Option { + let id = message.get("id")?; + let result = match message["method"].as_str()? { + "server/discover" => { + return Some( + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Use initialize"}}), + ) + } + "initialize" => { + json!({"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"fixture","version":"1"}}) + } + "tools/list" if message["params"]["cursor"] == "next" => { + json!({"tools":[{"name":"second","description":"Fixture page two","inputSchema":{"type":"object"}}]}) + } + "tools/list" => { + json!({"tools":[{"name":"first","description":std::process::id().to_string(),"inputSchema":{"type":"object"}}],"nextCursor":"next"}) + } + _ => panic!("Probe attempted a method outside metadata listing"), + }; + Some(json!({"jsonrpc":"2.0","id":id,"result":result})) +} + +#[test] +fn fixture_server() { + if std::env::var("LITHE_MCP_PROBE_FIXTURE").as_deref() != Ok("1") { + return; + } + // Child-only blocking stdin is owned by the parent probe's 30-second + // deadline and mandatory process-tree kill, never the test executor. + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { + break; + }; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if value["method"] == "tools/list" { + if let Ok(address) = std::env::var("LITHE_MCP_FIXTURE_SIGNAL") { + let mut signal = std::net::TcpStream::connect(address).unwrap(); + write!(signal, "{}", std::process::id()).unwrap(); + continue; + } + } + if let Some(value) = response(&value) { + println!("{value}"); + std::io::stdout().flush().unwrap(); + } + } +} + +#[test] +fn real_stdio_probe_lists_pages_and_reaps_its_child() { + let value = execute(fixture_request(), &|| false).unwrap(); + assert_eq!(value["tools"][0]["name"], "first"); + assert_eq!(value["tools"][1]["name"], "second"); + #[cfg(unix)] + { + let pid: i32 = value["tools"][0]["description"] + .as_str() + .unwrap() + .parse() + .unwrap(); + // SAFETY: signal zero only checks whether the already-reaped child exists. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + } +} + +#[test] +fn cancellation_and_invalid_remote_inputs_do_not_launch_anything() { + assert_eq!( + execute(fixture_request(), &|| true).unwrap_err(), + Error::Cancelled + ); + for url in [ + "http://example.invalid/mcp", + "https://user:token@example.invalid/mcp", + "file:///tmp/mcp", + ] { + let mut r = fixture_request(); + r.transport = "http".into(); + r.url = Some(url.into()); + assert_eq!(execute(r, &|| false).unwrap_err(), Error::Invalid); + } +} + +#[tokio::test(start_paused = true)] +async fn silent_server_hits_virtual_deadline_and_cancellation_is_observed() { + let (client, _server) = tokio::io::duplex(4096); + let start = tokio::time::Instant::now(); + let result = inventory(client, &|| false).await; + assert_eq!(result.unwrap_err(), Error::Timeout); + assert!(start.elapsed() <= DEADLINE + Duration::from_secs(2)); + let (client, _server) = tokio::io::duplex(4096); + assert_eq!( + inventory(client, &|| true).await.unwrap_err(), + Error::Cancelled + ); +} + +#[tokio::test] +async fn cancellation_after_stdio_handshake_reaps_the_live_child() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut request = fixture_request(); + request.env.push(Entry { + key: "LITHE_MCP_FIXTURE_SIGNAL".into(), + value: listener.local_addr().unwrap().to_string(), + }); + let cancelled = std::cell::Cell::new(false); + let cancel = || cancelled.get(); + let signal = async { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut pid = String::new(); + stream.read_to_string(&mut pid).await.unwrap(); + cancelled.set(true); + pid + }; + let (result, pid) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(run(request, &cancel), signal) + }) + .await + .unwrap(); + assert_eq!(result.unwrap_err(), Error::Cancelled); + #[cfg(unix)] + { + let pid: i32 = pid.parse().unwrap(); + // SAFETY: signal zero only checks for the reaped fixture process. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + } + #[cfg(not(unix))] + let _ = pid; +} + +#[tokio::test] +async fn real_loopback_http_probe_uses_the_same_inventory_protocol() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + for mode in ["json", "sse", "oversized-header", "oversized-body"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + let mut first = String::new(); + reader.read_line(&mut first).await.unwrap(); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_lowercase().strip_prefix("content-length:") { + length = value.trim().parse::().unwrap(); + } + } + assert!(length < 16384); + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).await.unwrap(); + let body = if first.starts_with("POST") { + response(&serde_json::from_slice(&bytes).unwrap()) + } else { + None + }; + let mut text = body.map(|v| v.to_string()).unwrap_or_default(); + let content_type = if mode == "sse" { + "text/event-stream" + } else { + "application/json" + }; + if mode == "sse" && !text.is_empty() { + text = format!("event: message\ndata: {text}\n\n"); + } + let reply = if mode == "oversized-header" { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + MAX_BYTES + 1 + ) + } else if mode == "oversized-body" { + // No length header: enforce the cap while streaming, before JSON decode. + format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", "x".repeat(MAX_BYTES as usize + 1)) + } else { + format!("HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", if text.is_empty() { "202 Accepted" } else { "200 OK" }, content_type, text.len(), text) + }; + // The client may reject the oversized response before all bytes are sent. + if reader.get_mut().write_all(reply.as_bytes()).await.is_err() { + break; + } + } + }); + let mut request = fixture_request(); + request.transport = "http".into(); + request.url = Some(format!("http://{address}/mcp")); + let result = tokio::time::timeout(Duration::from_secs(5), run(request, &|| false)).await; + server.abort(); + let _ = server.await; + let result = result.unwrap(); + if mode.starts_with("oversized") { + assert_eq!(result.unwrap_err(), Error::Connection); + } else { + assert_eq!(result.unwrap()["tools"].as_array().unwrap().len(), 2); + } + } +} diff --git a/rust/lithe-agent-host/src/skills.rs b/rust/lithe-agent-host/src/skills.rs new file mode 100644 index 0000000000..fbb87da580 --- /dev/null +++ b/rust/lithe-agent-host/src/skills.rs @@ -0,0 +1,281 @@ +//! Native Agent skill discovery and explicit user-owned skill installation. +//! +//! Codex owns discovery through skills/list. Claude's directory inventory is +//! deliberately not advertised as a complete or enabled session capability list. +//! Explicit toggles use native settings; discovery never changes configuration. + +mod files; +mod settings; +#[cfg(test)] +mod tests; + +use crate::{codex_history::CodexHistory, environment}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +/// Explicit settings action. Reading a list never installs or enables a skill. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub action: Action, + pub agent_id: String, + /// Current opened project, or None for user-only settings. + pub workspace: Option, + pub scope: Option, + pub name: Option, + pub description: Option, + pub instructions: Option, + /// User-selected SKILL.md or a directory containing it; never an archive or URL. + pub source: Option, + /// Desired native visibility; required only for setEnabled. + pub enabled: Option, +} + +/// Mutations require an explicit settings action, never a discovery request. +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Action { + List, + Directories, + Read, + Create, + Import, + SetEnabled, +} + +/// A native path is transient platform metadata, not a persistent shared identifier. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Skill { + name: String, + description: String, + path: PathBuf, + scope: String, + /// Native Codex state, or Claude's configured visibility in this scope. + /// None means configuration could not be read, never an inferred default. + enabled: Option, + plugin_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Listing { + skills: Vec, + /// "agent" means native discovery; "directory" means an explicitly partial inventory. + source: &'static str, + issues: Vec, + directories: std::collections::BTreeMap, +} + +/// Failures retain a stable category without returning raw subprocess output. +#[derive(Debug)] +pub enum Error { + Invalid(String), + Failed(String), + MissingCLI, + Cancelled, +} + +impl Error { + pub fn message(&self) -> &str { + match self { + Self::Invalid(message) | Self::Failed(message) => message, + Self::MissingCLI => "Install Codex CLI to discover its skills.", + Self::Cancelled => "Skill operation was cancelled.", + } + } +} + +/// Run on a platform worker. The caller owns cancellation and the current workspace. +pub fn execute(request: Request, cancelled: &dyn Fn() -> bool) -> Result { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .ok_or_else(|| Error::Failed("The user home directory is unavailable.".into()))?; + let claude = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".claude")); + execute_at(request, &home, &claude, cancelled) +} + +fn execute_at( + request: Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + check(cancelled)?; + if !matches!(request.agent_id.as_str(), "codex-acp" | "claude-acp") { + return Err(Error::Invalid( + "Skill management is available for Codex and Claude.".into(), + )); + } + if request + .workspace + .as_ref() + .is_some_and(|p| !p.is_absolute() || !p.is_dir()) + || !claude.is_absolute() + { + return Err(Error::Invalid( + "The skill workspace and configuration directory must be absolute directories.".into(), + )); + } + if request.action == Action::Directories { + return Ok(json!({"directories": files::directories(&request, home, claude)})); + } + if request.action == Action::Read { + let path = request + .source + .as_deref() + .filter(|p| p.is_absolute()) + .ok_or_else(|| Error::Invalid("Choose a skill document.".into()))?; + return files::read_document(path).map(|content| json!({"content": content})); + } + if request.action == Action::SetEnabled { + return settings::set_enabled(&request, home, claude, cancelled); + } + if request.action != Action::List { + return files::install(&request, home, claude, cancelled).map(|path| json!({"path": path})); + } + let mut listing = if request.agent_id == "codex-acp" { + let cli = environment::detect_tool("codex", cancelled).ok_or(Error::MissingCLI)?; + check(cancelled)?; + let cwd = request.workspace.as_deref().unwrap_or(home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| Error::Failed("Skill discovery could not start.".into()))?; + let result = runtime.block_on(CodexHistory::skills(cli.path, cwd.to_path_buf(), cancelled)); + check(cancelled)?; + let value = result.map_err(|_| { + Error::Failed("Codex skill discovery failed. Check the CLI and retry.".into()) + })?; + parse_codex(&value, cwd)? + } else { + files::claude_inventory(request.workspace.as_deref(), claude, cancelled)? + }; + if request.agent_id == "claude-acp" { + settings::annotate(&mut listing, request.workspace.as_deref(), claude); + } + check(cancelled)?; + listing_value(listing, &request, home, claude) +} + +fn listing_value( + mut listing: Listing, + request: &Request, + home: &Path, + claude: &Path, +) -> Result { + listing.directories = files::directories(request, home, claude); + listing.skills.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then(a.path.cmp(&b.path)) + }); + serde_json::to_value(listing) + .map_err(|_| Error::Failed("Skill metadata could not be returned.".into())) +} + +fn check(cancelled: &dyn Fn() -> bool) -> Result<(), Error> { + if cancelled() { + Err(Error::Cancelled) + } else { + Ok(()) + } +} + +fn parse_codex(value: &Value, cwd: &Path) -> Result { + let entries = value["data"] + .as_array() + .ok_or_else(|| Error::Failed("Codex returned invalid skill metadata.".into()))?; + let entry = entries + .iter() + .find(|entry| { + entry["cwd"].as_str().is_some_and(|path| { + Path::new(path) == cwd + || std::fs::canonicalize(path) + .ok() + .zip(std::fs::canonicalize(cwd).ok()) + .is_some_and(|(a, b)| a == b) + }) + }) + .ok_or_else(|| { + Error::Failed("Codex did not report skills for the selected project.".into()) + })?; + let rows = entry["skills"] + .as_array() + .ok_or_else(|| Error::Failed("Codex returned invalid skill metadata.".into()))?; + if rows.len() > files::MAX_ENTRIES { + return Err(Error::Failed("Too many skills to display.".into())); + } + let mut listing = Listing { + skills: vec![], + source: "agent", + issues: vec![], + directories: Default::default(), + }; + for row in rows { + let path = row["path"].as_str().map(PathBuf::from); + match (row["name"].as_str(), row["description"].as_str(), path) { + (Some(name), Some(description), Some(path)) + if path.is_absolute() && !name.is_empty() => + { + if !matches!( + row["scope"].as_str(), + Some("user" | "repo" | "system" | "admin") + ) { + listing + .issues + .push("A skill has an unknown scope and cannot be classified.".into()); + continue; + } + if listing.skills.iter().any(|skill| skill.path == path) { + continue; + } + listing.skills.push(Skill { + name: name.into(), + description: description.into(), + path, + scope: row["scope"].as_str().unwrap_or("unknown").into(), + enabled: row["enabled"].as_bool(), + plugin_id: row["pluginId"].as_str().map(str::to_owned), + }); + } + _ => listing + .issues + .push("A skill was omitted because its native metadata is incomplete.".into()), + } + } + if entry["errors"] + .as_array() + .is_some_and(|errors| !errors.is_empty()) + { + listing.issues.push( + "Codex reported skill discovery errors. Check the affected SKILL.md files and refresh." + .into(), + ); + } + Ok(listing) +} + +/// Restrict config writes to a path that this workspace's native discovery reports. +pub(crate) fn codex_skill_path(value: &Value, cwd: &Path, path: &Path) -> Option { + parse_codex(value, cwd).ok().and_then(|listing| { + listing + .skills + .into_iter() + .find(|skill| same_path(&skill.path, path) && skill.enabled.is_some()) + .map(|skill| skill.path) + }) +} + +fn same_path(left: &Path, right: &Path) -> bool { + left == right + || std::fs::canonicalize(left) + .ok() + .zip(std::fs::canonicalize(right).ok()) + .is_some_and(|(left, right)| left == right) +} diff --git a/rust/lithe-agent-host/src/skills/files.rs b/rust/lithe-agent-host/src/skills/files.rs new file mode 100644 index 0000000000..5c2d15875e --- /dev/null +++ b/rust/lithe-agent-host/src/skills/files.rs @@ -0,0 +1,523 @@ +//! Bounded directory inventory and non-overwriting publication in native user/project roots. + +use super::*; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; + +pub(super) const MAX_ENTRIES: usize = 2_000; +const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_DOCUMENT_BYTES: u64 = 128 * 1024; +const MAX_IMPORT_BYTES: u64 = 32 * 1024 * 1024; +const MAX_DEPTH: usize = 20; + +fn failure(message: &str) -> Error { + Error::Failed(message.into()) +} + +fn read(path: &Path) -> Result, Error> { + read_limited(path, MAX_FILE_BYTES) +} + +fn read_limited(path: &Path, limit: u64) -> Result, Error> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let file = options + .open(path) + .map_err(|_| failure("The skill file could not be opened."))?; + if !file + .metadata() + .map_err(|_| failure("The skill file could not be inspected."))? + .is_file() + { + return Err(failure("Skills must contain regular files.")); + } + let mut bytes = Vec::new(); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|_| failure("The skill file could not be read."))?; + if bytes.len() as u64 > limit { + return Err(failure(if limit == MAX_DOCUMENT_BYTES { + "SKILL.md exceeds the 128 KiB limit." + } else { + "A skill file exceeds the 8 MiB limit." + })); + } + Ok(bytes) +} + +pub(super) fn metadata(bytes: &[u8], fallback: &str) -> Result<(String, String), Error> { + let text = std::str::from_utf8(bytes).map_err(|_| failure("SKILL.md must use UTF-8 text."))?; + let mut lines = text.trim_start_matches('\u{feff}').lines(); + if lines.next().map(str::trim) != Some("---") { + return Err(failure("SKILL.md needs YAML frontmatter.")); + } + let mut yaml = String::new(); + let mut closed = false; + for line in lines.by_ref() { + if line.trim() == "---" { + closed = true; + break; + } + yaml.push_str(line); + yaml.push('\n'); + } + if !closed { + return Err(failure("SKILL.md frontmatter is not closed.")); + } + if !lines.any(|line| !line.trim().is_empty()) { + return Err(failure( + "SKILL.md needs instructions after its frontmatter.", + )); + } + let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml) + .map_err(|_| failure("SKILL.md has invalid YAML frontmatter."))?; + let name = value["name"].as_str().unwrap_or(fallback).trim(); + let description = value["description"].as_str().unwrap_or("").trim(); + if name.is_empty() || description.is_empty() { + return Err(failure("SKILL.md needs a name and description.")); + } + Ok((name.into(), description.into())) +} + +/// Inventory only these two explicit locations. Do not emulate Claude's policy, +/// plugin resolution, ancestor discovery or lazy nested skill loading. +pub(super) fn claude_inventory( + workspace: Option<&Path>, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let mut result = Listing { + source: "directory", + skills: vec![], + issues: vec![], + directories: Default::default(), + }; + let mut roots = vec![(claude.join("skills"), "user")]; + if let Some(workspace) = workspace { + roots.push((workspace.join(".claude/skills"), "repo")); + } + let mut inspected = 0usize; + 'roots: for (root, scope) in roots { + check(cancelled)?; + let entries = match fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => { + result + .issues + .push(format!("Cannot read {}", root.display())); + continue; + } + }; + for entry in entries { + check(cancelled)?; + inspected += 1; + if inspected > MAX_ENTRIES { + result + .issues + .push("Only the first 2000 directory entries were inspected.".into()); + break 'roots; + } + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + result + .issues + .push("A skill directory could not be read.".into()); + continue; + } + }; + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + let path = entry.path().join("SKILL.md"); + if !path.exists() { + continue; + } + // A native skill directory may be a link. Resolve only for reading; + // importing links or writing through linked destinations is forbidden. + let parsed = fs::canonicalize(&path) + .map_err(|_| failure("A skill path could not be resolved.")) + .and_then(|canonical| read_limited(&canonical, MAX_DOCUMENT_BYTES)) + .and_then(|bytes| metadata(&bytes, &entry.file_name().to_string_lossy())); + match parsed { + Ok((name, description)) => result.skills.push(Skill { + name, + description, + path, + scope: scope.into(), + enabled: None, + plugin_id: None, + }), + Err(error) => { + result + .issues + .push(format!("{}: {}", path.display(), error.message())) + } + } + } + } + Ok(result) +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// Create native containers without following a linked intermediate directory. +fn destination(request: &Request, home: &Path, claude: &Path) -> Result { + let project = request.scope.as_deref() == Some("repo"); + if !matches!(request.scope.as_deref(), Some("user" | "repo")) { + return Err(Error::Invalid( + "Choose Global or Project for the skill.".into(), + )); + } + let (base, components) = if project { + ( + request.workspace.as_deref().ok_or_else(|| { + Error::Invalid("Open a project before creating project skills.".into()) + })?, + vec![ + if request.agent_id == "codex-acp" { + ".agents" + } else { + ".claude" + }, + "skills", + ], + ) + } else if request.agent_id == "codex-acp" { + (home, vec![".agents", "skills"]) + } else { + // CLAUDE_CONFIG_DIR is the user's native configuration override. + fs::create_dir_all(claude) + .map_err(|_| failure("The Claude configuration directory could not be created."))?; + (claude, vec!["skills"]) + }; + let mut path = + fs::canonicalize(base).map_err(|_| failure("The skill destination is unavailable."))?; + for component in components { + path.push(component); + match fs::create_dir(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(failure("The skill destination could not be created.")), + } + let metadata = fs::symlink_metadata(&path) + .map_err(|_| failure("The skill destination could not be inspected."))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(failure("The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory.")); + } + } + Ok(path) +} + +struct Staging(PathBuf); +impl Drop for Staging { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("Skill staging cleanup failed: {error}"); + } + } + } +} + +pub(super) fn install( + request: &Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let (name, content, source) = match request.action { + Action::Create => { + let name = request.name.as_deref().unwrap_or(""); + let description = request.description.as_deref().unwrap_or("").trim(); + let instructions = request.instructions.as_deref().unwrap_or("").trim(); + if description.is_empty() || instructions.is_empty() { + return Err(Error::Invalid( + "Enter a description and instructions.".into(), + )); + } + // JSON strings are valid YAML scalars, preserving quotes and newlines. + let text = format!( + "---\nname: {}\ndescription: {}\n---\n\n{}\n", + json!(name), + json!(description), + instructions + ); + if text.len() as u64 > MAX_DOCUMENT_BYTES { + return Err(Error::Invalid("The skill is too large.".into())); + } + (name.to_owned(), Some(text.into_bytes()), None) + } + Action::Import => { + let source = request + .source + .as_deref() + .filter(|p| p.is_absolute()) + .ok_or_else(|| { + Error::Invalid("Choose a SKILL.md file or skill directory.".into()) + })?; + let info = fs::symlink_metadata(source) + .map_err(|_| failure("The import source is unavailable."))?; + if info.file_type().is_symlink() { + return Err(failure( + "Import a regular file or directory instead of a symbolic link.", + )); + } + let file = if info.is_dir() { + source.join("SKILL.md") + } else { + source.to_path_buf() + }; + if file.file_name().and_then(|v| v.to_str()) != Some("SKILL.md") { + return Err(Error::Invalid( + "Select SKILL.md or its containing directory.".into(), + )); + } + let bytes = read_limited(&file, MAX_DOCUMENT_BYTES)?; + let fallback = if info.is_dir() { + source.file_name().and_then(|v| v.to_str()).unwrap_or("") + } else { + "" + }; + let (name, _) = metadata(&bytes, fallback)?; + if request.agent_id == "codex-acp" { + // Codex requires an explicit frontmatter name, unlike Claude's directory fallback. + let (explicit, _) = metadata(&bytes, "")?; + if explicit.is_empty() { + return Err(failure("Codex requires a skill name.")); + } + } + ( + name, + Some(bytes), + info.is_dir().then(|| source.to_path_buf()), + ) + } + Action::List | Action::Read | Action::Directories | Action::SetEnabled => unreachable!(), + }; + if !valid_name(&name) { + return Err(Error::Invalid( + "Use up to 64 lowercase letters, digits and single hyphens for the skill name.".into(), + )); + } + if request.agent_id == "claude-acp" && matches!(name.as_str(), "synced" | "anthropic-skills") { + return Err(Error::Invalid( + "That skill name is reserved by Claude. Choose a different name.".into(), + )); + } + check(cancelled)?; + let root = destination(request, home, claude)?; + if source + .as_ref() + .and_then(|source| fs::canonicalize(source).ok()) + .is_some_and(|source| root.starts_with(source)) + { + return Err(Error::Invalid( + "The import source cannot contain the destination directory.".into(), + )); + } + let target = root.join(&name); + if target.symlink_metadata().is_ok() { + return Err(failure("A skill with this directory name already exists. Existing skills are never overwritten.")); + } + let staging = Staging(root.join(format!(".lithe-skill-{}", uuid::Uuid::new_v4()))); + fs::create_dir(&staging.0) + .map_err(|_| failure("The skill staging directory could not be created."))?; + if let Some(source) = source { + let mut budget = Budget { + entries: 0, + bytes: 0, + }; + copy_directory(&source, &staging.0, 0, &mut budget, cancelled)?; + // Validate the actual staged bytes, including concurrent source changes. + let (staged_name, _) = metadata( + &read_limited(&staging.0.join("SKILL.md"), MAX_DOCUMENT_BYTES)?, + &name, + )?; + if staged_name != name { + return Err(failure( + "SKILL.md changed while it was being imported. Retry the import.", + )); + } + } else if let Some(content) = content { + let mut file = File::create(staging.0.join("SKILL.md")) + .map_err(|_| failure("SKILL.md could not be created."))?; + file.write_all(&content) + .map_err(|_| failure("SKILL.md could not be saved."))?; + } + check(cancelled)?; + publish(&staging.0, &target)?; + Ok(target.join("SKILL.md")) +} + +struct Budget { + entries: usize, + bytes: u64, +} +fn copy_directory( + source: &Path, + destination: &Path, + depth: usize, + budget: &mut Budget, + cancelled: &dyn Fn() -> bool, +) -> Result<(), Error> { + if depth > MAX_DEPTH { + return Err(failure("The skill directory is too deeply nested.")); + } + let entries = + fs::read_dir(source).map_err(|_| failure("The skill directory could not be read."))?; + for entry in entries { + check(cancelled)?; + let entry = entry.map_err(|_| failure("A skill entry could not be read."))?; + budget.entries += 1; + if budget.entries > MAX_ENTRIES { + return Err(failure("The skill contains too many files.")); + } + let kind = entry + .file_type() + .map_err(|_| failure("A skill entry could not be inspected."))?; + let path = destination.join(entry.file_name()); + if kind.is_dir() { + fs::create_dir(&path).map_err(|_| failure("A skill directory could not be copied."))?; + copy_directory(&entry.path(), &path, depth + 1, budget, cancelled)?; + } else if kind.is_file() { + let bytes = read(&entry.path())?; + budget.bytes += bytes.len() as u64; + if budget.bytes > MAX_IMPORT_BYTES { + return Err(failure("The skill exceeds the 32 MiB import limit.")); + } + fs::write(&path, bytes).map_err(|_| failure("A skill file could not be copied."))?; + let permissions = entry + .metadata() + .map_err(|_| failure("Skill permissions could not be read."))? + .permissions(); + fs::set_permissions(&path, permissions) + .map_err(|_| failure("Skill permissions could not be preserved."))?; + } else { + return Err(failure( + "Skill imports cannot contain symbolic links or special files.", + )); + } + } + Ok(()) +} + +/// Atomic no-replace publication closes the existence-check race with another window. +pub(super) fn publish(source: &Path, target: &Path) -> Result<(), Error> { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let from = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| failure("Invalid skill path."))?; + let to = CString::new(target.as_os_str().as_bytes()) + .map_err(|_| failure("Invalid skill path."))?; + #[cfg(target_os = "macos")] + // SAFETY: both C strings remain alive for the call; flags refuse replacement. + let result = unsafe { + libc::renameatx_np( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_EXCL, + ) + }; + #[cfg(target_os = "linux")] + // SAFETY: both C strings remain alive for the call; flags refuse replacement. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result != 0 { + return Err(failure( + "The skill could not be published. Check permissions and existing skill names.", + )); + } + Ok(()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; + let from: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let to: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); + if from[..from.len() - 1].contains(&0) || to[..to.len() - 1].contains(&0) { + return Err(failure("Invalid skill path.")); + } + // std::fs::rename can replace empty directories via FileRenameInfoEx. + // Omit REPLACE_EXISTING and COPY_ALLOWED: publication must stay an + // exclusive same-volume move, including when another writer races us. + // SAFETY: both terminated UTF-16 buffers stay alive throughout the call. + if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 { + return Err(failure( + "The skill could not be published. Check permissions and existing skill names.", + )); + } + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + Err(failure( + "Atomic skill publication is unavailable on this platform.", + )) +} + +/// Native destinations are returned as display metadata; discovery creates no directories. +pub(super) fn directories( + request: &Request, + home: &Path, + claude: &Path, +) -> std::collections::BTreeMap { + let mut directories = std::collections::BTreeMap::new(); + directories.insert( + "user".into(), + if request.agent_id == "codex-acp" { + home.join(".agents/skills") + } else { + claude.join("skills") + }, + ); + if let Some(workspace) = &request.workspace { + directories.insert( + "repo".into(), + workspace.join(if request.agent_id == "codex-acp" { + ".agents/skills" + } else { + ".claude/skills" + }), + ); + } + directories +} + +pub(super) fn read_document(path: &Path) -> Result { + if path.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") { + return Err(Error::Invalid( + "Select SKILL.md or its containing directory.".into(), + )); + } + let canonical = fs::canonicalize(path) + .map_err(|_| failure("The skill document is unavailable. Refresh and retry."))?; + String::from_utf8(read_limited(&canonical, MAX_DOCUMENT_BYTES)?) + .map_err(|_| failure("SKILL.md must use UTF-8 text.")) +} diff --git a/rust/lithe-agent-host/src/skills/settings.rs b/rust/lithe-agent-host/src/skills/settings.rs new file mode 100644 index 0000000000..9273abd610 --- /dev/null +++ b/rust/lithe-agent-host/src/skills/settings.rs @@ -0,0 +1,298 @@ +//! Native visibility settings. Never move skill packages or keep a Lithe enabled flag. + +use super::*; +use fs2::FileExt; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; + +const MAX_SETTINGS_BYTES: u64 = 1024 * 1024; + +fn failed() -> Error { + Error::Failed( + "Skill settings could not be updated. Check the settings file and refresh.".into(), + ) +} + +/// These are configured states, not a reconstruction of Claude's effective +/// managed/ancestor/session policy. Project-local overrides take precedence. +pub(super) fn annotate(listing: &mut Listing, workspace: Option<&Path>, claude: &Path) { + let user = load(&claude.join("settings.json")); + let project = workspace + .map(|root| { + Ok::<_, Error>(( + load(&root.join(".claude/settings.json"))?, + load(&root.join(".claude/settings.local.json"))?, + )) + }) + .transpose(); + for skill in &mut listing.skills { + skill.enabled = user.as_ref().ok().and_then(|user| { + let mut state = visibility(user, &skill.name)?; + if skill.scope == "repo" { + if let Some((shared, local)) = project.as_ref().ok()?.as_ref() { + state = override_visibility(shared, &skill.name)?.unwrap_or(state); + state = override_visibility(local, &skill.name)?.unwrap_or(state); + } + } + Some(state) + }); + } + if user.is_err() || project.is_err() { + listing.issues.push("Claude skill settings could not be read. Fix invalid settings and refresh before toggling.".into()); + } +} + +fn override_visibility(value: &Value, name: &str) -> Option> { + match value + .get("skillOverrides") + .and_then(|values| values.get(name)) + .and_then(Value::as_str) + { + None => Some(None), + Some("off") => Some(Some(false)), + Some("on" | "name-only" | "user-invocable-only") => Some(Some(true)), + Some(_) => None, + } +} + +fn visibility(value: &Value, name: &str) -> Option { + Some(override_visibility(value, name)?.unwrap_or(true)) +} + +pub(super) fn set_enabled( + request: &Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let enabled = request + .enabled + .ok_or_else(|| Error::Invalid("Choose whether to enable the skill.".into()))?; + let path = request + .source + .as_deref() + .filter(|path| path.is_absolute()) + .ok_or_else(|| Error::Invalid("Choose a skill document.".into()))?; + if request.agent_id == "codex-acp" { + let cli = environment::detect_tool("codex", cancelled).ok_or(Error::MissingCLI)?; + check(cancelled)?; + let cwd = request.workspace.as_deref().unwrap_or(home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| failed())?; + let value = runtime.block_on(CodexHistory::set_skill_enabled( + cli.path, + cwd.to_owned(), + path.to_owned(), + enabled, + cancelled, + )); + check(cancelled)?; + let listing = parse_codex(&value.map_err(|_| Error::Failed("Codex skill settings could not be confirmed. Refresh to check the current state.".into()))?, cwd)?; + if !listing + .skills + .iter() + .any(|skill| same_path(&skill.path, path) && skill.enabled == Some(enabled)) + { + return Err(Error::Failed("The Agent did not apply the skill setting. Refresh and check project or managed configuration.".into())); + } + return listing_value(listing, request, home, claude); + } + let mut listing = files::claude_inventory(request.workspace.as_deref(), claude, cancelled)?; + let discovery_issue_count = listing.issues.len(); + annotate(&mut listing, request.workspace.as_deref(), claude); + let skill = listing + .skills + .iter() + .find(|skill| { + same_path(&skill.path, path) && Some(skill.scope.as_str()) == request.scope.as_deref() + }) + .ok_or_else(|| { + Error::Invalid( + "The skill is no longer available in this scope. Refresh and retry.".into(), + ) + })?; + if skill.enabled.is_none() { + return Err(failed()); + } + // SkillOverrides keys are names. Duplicate names in the same scope share one setting. + if skill.name.contains(':') { + return Err(Error::Invalid( + "Claude plugin skills must be managed through Claude's plugin settings.".into(), + )); + } + let target = if skill.scope == "repo" { + request + .workspace + .as_ref() + .ok_or_else(failed)? + .join(".claude/settings.local.json") + } else { + claude.join("settings.json") + }; + write_override(&target, &skill.name, enabled, cancelled)?; + listing.issues.truncate(discovery_issue_count); + annotate(&mut listing, request.workspace.as_deref(), claude); + if !listing + .skills + .iter() + .any(|skill| same_path(&skill.path, path) && skill.enabled == Some(enabled)) + { + return Err(Error::Failed("The Agent did not apply the skill setting. Refresh and check project or managed configuration.".into())); + } + listing_value(listing, request, home, claude) +} + +fn options() -> OpenOptions { + let mut options = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .mode(0o600); + } + options +} + +fn snapshot(path: &Path) -> Result>, Error> { + let file = match options().read(true).open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(failed()), + }; + if !file.metadata().map_err(|_| failed())?.is_file() + || fs::symlink_metadata(path) + .map_err(|_| failed())? + .file_type() + .is_symlink() + { + return Err(failed()); + } + let mut bytes = vec![]; + file.take(MAX_SETTINGS_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| failed())?; + if bytes.len() as u64 > MAX_SETTINGS_BYTES { + return Err(failed()); + } + Ok(Some(bytes)) +} + +fn parse(bytes: Option<&[u8]>) -> Result { + let value: Value = match bytes { + Some(bytes) => serde_json::from_slice(bytes).map_err(|_| failed())?, + None => json!({}), + }; + if !value.is_object() { + return Err(failed()); + } + if let Some(overrides) = value.get("skillOverrides") { + if !overrides.is_object() + || !overrides.as_object().unwrap().values().all(|value| { + matches!( + value.as_str(), + Some("on" | "off" | "name-only" | "user-invocable-only") + ) + }) + { + return Err(failed()); + } + } + Ok(value) +} + +fn load(path: &Path) -> Result { + parse(snapshot(path)?.as_deref()) +} + +struct Temporary(PathBuf); +impl Drop for Temporary { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.0) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("Skill settings staging cleanup failed: {error}"); + } + } + } +} + +pub(super) fn write_override( + path: &Path, + name: &str, + enabled: bool, + cancelled: &dyn Fn() -> bool, +) -> Result<(), Error> { + let parent = path.parent().ok_or_else(failed)?; + // Reject linked parents before writing, including a linked .claude directory. + let mut ancestors: Vec<_> = parent.ancestors().collect(); + ancestors.reverse(); + for ancestor in ancestors { + if !ancestor.exists() { + fs::create_dir(ancestor).map_err(|_| failed())?; + } + let metadata = fs::symlink_metadata(ancestor).map_err(|_| failed())?; + // macOS /var is a system link; resolve existing ancestors, but the actual + // settings directory itself must remain a regular native directory. + if !ancestor.is_dir() || (ancestor == parent && metadata.file_type().is_symlink()) { + return Err(failed()); + } + } + let lock_path = parent.join(".lithe-skill-settings.lock"); + let lock = options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|_| failed())?; + if !lock.metadata().map_err(|_| failed())?.is_file() + || fs::symlink_metadata(&lock_path) + .map_err(|_| failed())? + .file_type() + .is_symlink() + { + return Err(failed()); + } + // Nonblocking lock serializes Lithe windows; native/editor changes are + // checked against the exact input snapshot before atomic publication. + lock.try_lock_exclusive().map_err(|_| { + Error::Failed("Skill settings are busy. Retry after the other change finishes.".into()) + })?; + let before = snapshot(path)?; + let mut value = parse(before.as_deref())?; + value + .as_object_mut() + .ok_or_else(failed)? + .entry("skillOverrides") + .or_insert_with(|| json!({}))[name] = json!(if enabled { "on" } else { "off" }); + let mut bytes = serde_json::to_vec_pretty(&value).map_err(|_| failed())?; + bytes.push(b'\n'); + if bytes.len() as u64 > MAX_SETTINGS_BYTES { + return Err(failed()); + } + check(cancelled)?; + let staging = Temporary(parent.join(format!(".lithe-skill-settings-{}", uuid::Uuid::new_v4()))); + let mut file = options() + .write(true) + .create_new(true) + .open(&staging.0) + .map_err(|_| failed())?; + if let Ok(metadata) = fs::metadata(path) { + file.set_permissions(metadata.permissions()) + .map_err(|_| failed())?; + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| failed())?; + drop(file); + check(cancelled)?; + if snapshot(path)? != before { + return Err(Error::Failed( + "Skill settings changed elsewhere. Refresh and retry.".into(), + )); + } + fs::rename(&staging.0, path).map_err(|_| failed())?; + Ok(()) +} diff --git a/rust/lithe-agent-host/src/skills/tests.rs b/rust/lithe-agent-host/src/skills/tests.rs new file mode 100644 index 0000000000..aea2664378 --- /dev/null +++ b/rust/lithe-agent-host/src/skills/tests.rs @@ -0,0 +1,632 @@ +use super::*; +use std::fs; +use std::time::Duration; + +struct Sandbox(PathBuf); +impl Sandbox { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("lithe-skills-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("home")).unwrap(); + fs::create_dir_all(root.join("project")).unwrap(); + Self(fs::canonicalize(root).unwrap()) + } + fn run(&self, payload: Value) -> Result { + let mut payload = payload; + payload["workspace"] = json!(self.0.join("project")); + execute_at( + serde_json::from_value(payload).unwrap(), + &self.0.join("home"), + &self.0.join("home/.claude"), + &|| false, + ) + } +} +impl Drop for Sandbox { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} +fn create(agent: &str, scope: &str, name: &str) -> Value { + json!({"action":"create", "agentId":agent, "scope":scope, "name":name, + "description":"Use for Unicode 中文, quotes \" and newlines\nonly", "instructions":"# Workflow\n\n1. Inspect\n2. Explain"}) +} + +#[test] +fn claude_toggles_preserve_documents_other_settings_and_scope() { + let fixture = Sandbox::new(); + let user = fixture + .run(create("claude-acp", "user", "same-name")) + .unwrap(); + let repo = fixture + .run(create("claude-acp", "repo", "same-name")) + .unwrap(); + let user_settings = fixture.0.join("home/.claude/settings.json"); + let local_settings = fixture.0.join("project/.claude/settings.local.json"); + fs::write( + &user_settings, + r#"{"env":{"EXAMPLE":"keep"},"skillOverrides":{"unrelated":"name-only"}}"#, + ) + .unwrap(); + let document = fs::read(user["path"].as_str().unwrap()).unwrap(); + let toggle = |scope: &str, source: &Value, enabled: bool| { + fixture.run(json!({"action":"setEnabled", "agentId":"claude-acp", "scope":scope,"source":source,"enabled":enabled})) + }; + let result = toggle("user", &user["path"], false).unwrap(); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|skill| skill["enabled"] == false)); + assert!( + !local_settings.exists(), + "a global toggle must not write project settings" + ); + let result = toggle("repo", &repo["path"], true).unwrap(); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|skill| skill["enabled"] == (skill["scope"] == "repo"))); + assert!(!fixture.0.join("project/.claude/settings.json").exists()); + toggle("user", &user["path"], true).unwrap(); + let config: Value = serde_json::from_slice(&fs::read(user_settings).unwrap()).unwrap(); + assert_eq!(config["env"]["EXAMPLE"], "keep"); + assert_eq!(config["skillOverrides"]["unrelated"], "name-only"); + assert_eq!(config["skillOverrides"]["same-name"], "on"); + assert_eq!(fs::read(user["path"].as_str().unwrap()).unwrap(), document); +} + +#[test] +fn claude_invalid_settings_are_never_replaced_and_hide_unknown_state() { + let fixture = Sandbox::new(); + let result = fixture + .run(create("claude-acp", "repo", "test-skill")) + .unwrap(); + let target = fixture.0.join("project/.claude/settings.local.json"); + for invalid in [ + "invalid JSON", + "[]", + r#"{"skillOverrides":[]}"#, + r#"{"skillOverrides":{"other":"unexpected"}}"#, + ] { + fs::write(&target, invalid).unwrap(); + let listing = fixture + .run(json!({"action":"list","agentId":"claude-acp"})) + .unwrap(); + assert_eq!(listing["skills"][0]["enabled"], Value::Null); + assert!(!listing["issues"].as_array().unwrap().is_empty()); + assert!(fixture.run(json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":result["path"],"enabled":false})).is_err()); + assert_eq!(fs::read_to_string(&target).unwrap(), invalid); + } +} + +#[test] +fn claude_toggles_reject_missing_or_foreign_paths_and_missing_state() { + let fixture = Sandbox::new(); + let result = fixture + .run(create("claude-acp", "repo", "test-skill")) + .unwrap(); + for payload in [ + json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":result["path"]}), + json!({"action":"setEnabled","agentId":"claude-acp","scope":"user","source":result["path"],"enabled":false}), + json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":fixture.0.join("foreign/SKILL.md"),"enabled":false}), + ] { + assert!(fixture.run(payload).is_err()); + } + assert!(!fixture + .0 + .join("project/.claude/settings.local.json") + .exists()); +} + +#[test] +fn claude_settings_cancel_and_busy_lock_never_change_existing_bytes() { + use fs2::FileExt; + let fixture = Sandbox::new(); + let target = fixture.0.join("home/.claude/settings.json"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "{\"other\":true}").unwrap(); + assert!(settings::write_override(&target, "test", false, &|| true).is_err()); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open(target.parent().unwrap().join(".lithe-skill-settings.lock")) + .unwrap(); + lock.try_lock_exclusive().unwrap(); + assert!(settings::write_override(&target, "test", false, &|| false).is_err()); + drop(lock); + assert_eq!(fs::read_to_string(&target).unwrap(), "{\"other\":true}"); + assert_eq!( + fs::read_dir(target.parent().unwrap()).unwrap().count(), + 2, + "no temporary settings survive failure" + ); +} + +#[cfg(unix)] +#[test] +fn claude_settings_reject_symlinks_and_preserve_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let fixture = Sandbox::new(); + let target = fixture.0.join("home/.claude/settings.json"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + let outside = fixture.0.join("outside.json"); + fs::write(&outside, "{}").unwrap(); + symlink(&outside, &target).unwrap(); + assert!(settings::write_override(&target, "test", false, &|| false).is_err()); + assert_eq!(fs::read_to_string(&outside).unwrap(), "{}"); + fs::remove_file(&target).unwrap(); + fs::write(&target, "{}").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap(); + settings::write_override(&target, "test", false, &|| false).unwrap(); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} +const DOCUMENT: &str = + "---\nname: sample-skill\ndescription: Test skill\n---\n\nRead the supporting files.\n"; + +#[test] +fn create_is_native_scoped_non_overwriting_and_round_trips_document() { + let fixture = Sandbox::new(); + for agent in ["codex-acp", "claude-acp"] { + for scope in ["user", "repo"] { + let result = fixture.run(create(agent, scope, "new-skill")).unwrap(); + let path = PathBuf::from(result["path"].as_str().unwrap()); + let parent = if scope == "user" { + fixture.0.join("home") + } else { + fixture.0.join("project") + }; + assert!(path.starts_with(parent)); + assert!(path.to_string_lossy().contains(if agent == "codex-acp" { + ".agents" + } else { + ".claude" + })); + let bytes = fs::read(&path).unwrap(); + let parsed = files::read_document(&path).unwrap(); + assert!(parsed.contains("中文")); + assert_eq!(files::metadata(&bytes, "").unwrap().0, "new-skill"); + assert!(fixture.run(create(agent, scope, "new-skill")).is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes); + } + } +} + +#[test] +fn import_keeps_full_package_and_never_changes_the_source() { + let fixture = Sandbox::new(); + let source = fixture.0.join("external"); + fs::create_dir_all(source.join("scripts")).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("scripts/helper.sh"), "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + source.join("scripts/helper.sh"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + let result = fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"repo", "source":source})) + .unwrap(); + let copied = PathBuf::from(result["path"].as_str().unwrap()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(copied.parent().unwrap().join("scripts/helper.sh")) + .unwrap() + .permissions() + .mode(), + fs::metadata(source.join("scripts/helper.sh")) + .unwrap() + .permissions() + .mode() + ); + } + assert_eq!(fs::read(&copied).unwrap(), DOCUMENT.as_bytes()); + assert_eq!( + fs::read(copied.parent().unwrap().join("scripts/helper.sh")).unwrap(), + fs::read(source.join("scripts/helper.sh")).unwrap() + ); + assert_eq!( + fs::read(source.join("SKILL.md")).unwrap(), + DOCUMENT.as_bytes() + ); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"repo", "source":source})) + .is_err()); +} + +#[test] +fn single_file_import_and_claude_directory_fallback_are_supported() { + let fixture = Sandbox::new(); + let source = fixture.0.join("SKILL.md"); + fs::write(&source, DOCUMENT).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"claude-acp", "scope":"user", "source":source})) + .is_ok()); + let directory = fixture.0.join("folder-skill"); + fs::create_dir(&directory).unwrap(); + fs::write( + directory.join("SKILL.md"), + "---\ndescription: Folder fallback\n---\n\nInstructions\n", + ) + .unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":directory})) + .is_err()); + let result = fixture + .run(json!({"action":"import", "agentId":"claude-acp", "scope":"user", "source":directory})) + .unwrap(); + assert!(result["path"].as_str().unwrap().contains("folder-skill")); +} + +#[test] +fn directory_inventory_reports_configured_visibility_and_malformed_issues() { + let fixture = Sandbox::new(); + fixture + .run(create("claude-acp", "user", "user-skill")) + .unwrap(); + fixture + .run(create("claude-acp", "repo", "project-skill")) + .unwrap(); + let bad = fixture.0.join("project/.claude/skills/broken"); + fs::create_dir(&bad).unwrap(); + fs::write(bad.join("SKILL.md"), "---\nname: [invalid\n---\n").unwrap(); + let result = fixture + .run(json!({"action":"list", "agentId":"claude-acp"})) + .unwrap(); + assert_eq!(result["source"], "directory"); + assert_eq!(result["skills"].as_array().unwrap().len(), 2); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|row| row["enabled"] == true)); + assert_eq!(result["issues"].as_array().unwrap().len(), 1); + let directory = PathBuf::from(result["directories"]["repo"].as_str().unwrap()); + assert!(directory.ends_with(PathBuf::from(".claude").join("skills"))); +} + +#[test] +fn invalid_names_yaml_and_missing_workspace_do_not_publish_any_skill() { + let fixture = Sandbox::new(); + for name in ["../escape", "UPPER", "", "a--b", "/absolute", "a/b"] { + assert!(fixture.run(create("codex-acp", "repo", name)).is_err()); + } + let payload = create("codex-acp", "repo", "valid-name"); + assert!(execute_at( + serde_json::from_value(payload).unwrap(), + &fixture.0.join("home"), + &fixture.0.join("home/.claude"), + &|| false + ) + .is_err()); + let source = fixture.0.join("SKILL.md"); + for text in [ + "No frontmatter", + "---\nname: no-close", + "---\n[]\n---\n", + "---\nname: test\n---\nBody", + ] { + fs::write(&source, text).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + } + assert!(!fixture.0.join("project/.agents").exists()); +} + +#[cfg(unix)] +#[test] +fn linked_destinations_and_import_resources_are_rejected_without_partial_publish() { + use std::os::unix::fs::symlink; + let fixture = Sandbox::new(); + let outside = fixture.0.join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, fixture.0.join("project/.agents")).unwrap(); + assert!(fixture + .run(create("codex-acp", "repo", "linked-skill")) + .is_err()); + assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); + let source = fixture.0.join("package"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + symlink(&outside, source.join("assets")).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + let root = fixture.0.join("home/.agents/skills"); + assert_eq!( + fs::read_dir(root).unwrap().count(), + 0, + "failed import cleans staging and publishes nothing" + ); +} + +#[test] +fn cancel_during_package_copy_cleans_staging_and_preserves_existing_skill() { + let fixture = Sandbox::new(); + fixture + .run(create("codex-acp", "user", "existing")) + .unwrap(); + let source = fixture.0.join("package"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("other.txt"), "resource").unwrap(); + let calls = std::cell::Cell::new(0); + let cancel = || { + calls.set(calls.get() + 1); + calls.get() >= 5 + }; + let request: Request = serde_json::from_value( + json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source}), + ) + .unwrap(); + assert!(matches!( + execute_at( + request, + &fixture.0.join("home"), + &fixture.0.join("home/.claude"), + &cancel + ), + Err(Error::Cancelled) + )); + let root = fixture.0.join("home/.agents/skills"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(root.join("existing/SKILL.md").is_file()); +} + +#[test] +fn publication_cannot_replace_an_existing_empty_directory() { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let target = fixture.0.join("target"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + // The final native move must reject a destination created after staging, + // even when the competing directory is empty. + fs::create_dir(&target).unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert_eq!(fs::read_dir(target).unwrap().count(), 0); + assert!(source.join("SKILL.md").is_file()); +} + +#[test] +fn publication_preserves_existing_files_and_directory_contents() { + for directory in [false, true] { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let target = fixture.0.join("target"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + let existing = if directory { + fs::create_dir(&target).unwrap(); + target.join("keep.txt") + } else { + target.clone() + }; + fs::write(&existing, "keep existing bytes").unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert_eq!(fs::read_to_string(existing).unwrap(), "keep existing bytes"); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + DOCUMENT + ); + } +} + +#[test] +fn publication_moves_a_complete_package_to_a_missing_unicode_destination() { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage with spaces"); + let target = fixture.0.join("skill 中文"); + fs::create_dir_all(source.join("scripts")).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("scripts/example.txt"), "resource bytes").unwrap(); + files::publish(&source, &target).unwrap(); + assert!(!source.exists()); + assert_eq!( + fs::read_to_string(target.join("SKILL.md")).unwrap(), + DOCUMENT + ); + assert_eq!( + fs::read_to_string(target.join("scripts/example.txt")).unwrap(), + "resource bytes" + ); +} + +#[cfg(windows)] +#[test] +fn publication_rejects_nul_paths_without_publishing_the_truncated_name() { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let prefix = fixture.0.join("target"); + let mut name: Vec = prefix.as_os_str().encode_wide().collect(); + name.extend([0, b'x' as u16]); + let target = PathBuf::from(std::ffi::OsString::from_wide(&name)); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert!(!prefix.exists()); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + DOCUMENT + ); +} + +#[test] +fn native_response_keeps_disabled_scope_and_rejects_foreign_workspace() { + let fixture = Sandbox::new(); + let cwd = fixture.0.join("project"); + let value = json!({"data":[{"cwd":cwd, "skills":[ + {"name":"native-skill", "description":"Reported", "path":fixture.0.join("SKILL.md"), "scope":"system", "enabled":false}],"errors":[]}]}); + let listing = parse_codex(&value, &cwd).unwrap(); + assert_eq!(listing.skills[0].enabled, Some(false)); + assert_eq!(listing.skills[0].scope, "system"); + assert!(parse_codex(&value, &fixture.0.join("foreign")).is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn native_discovery_only_initializes_and_lists_without_creating_or_prompting() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("fake-codex"); + let response = json!({"data":[{"cwd":fixture.0.join("project"), "skills":[], "errors":[]}]}); + let script = format!("#!/bin/sh\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\" >> '{}'/requests.log\n case \"$line\" in\n *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{}}}}' ;;\n *'\"method\":\"skills/list\"'*) printf '%s\\n' '{{\"id\":2,\"result\":{response}}}' ;;\n esac\ndone\n", fixture.0.display()); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::skills(executable, fixture.0.join("project"), &|| false), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result, response); + let requests: Vec = fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + requests + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + ["initialize", "initialized", "skills/list"] + ); + assert_eq!(requests[2]["params"]["forceReload"], true); +} + +#[cfg(unix)] +#[tokio::test] +async fn native_toggle_validates_path_writes_native_config_and_reads_back() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("fake-codex"); + let path = fixture.0.join("project/.agents/skills/test/SKILL.md"); + let listing = |enabled| json!({"data":[{"cwd":fixture.0.join("project"),"skills":[{"name":"test","description":"Test","path":path,"scope":"repo","enabled":enabled}],"errors":[]}]}); + let script = format!("#!/bin/sh\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\" >> '{}'/requests.log\n case \"$line\" in\n *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{}}}}' ;;\n *'\"id\":2,'*) printf '%s\\n' '{{\"id\":2,\"result\":{}}}' ;;\n *'\"id\":3,'*) printf '%s\\n' '{{\"id\":3,\"result\":{{}}}}' ;;\n *'\"id\":4,'*) printf '%s\\n' '{{\"id\":4,\"result\":{}}}' ;;\n esac\ndone\n", fixture.0.display(), listing(true), listing(false)); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::set_skill_enabled( + executable.clone(), + fixture.0.join("project"), + path.clone(), + false, + &|| false, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result, listing(false)); + let requests: Vec = fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + requests + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + [ + "initialize", + "initialized", + "skills/list", + "skills/config/write", + "skills/list" + ] + ); + assert_eq!(requests[3]["params"], json!({"path":path,"enabled":false})); + fs::remove_file(fixture.0.join("requests.log")).unwrap(); + let rejected = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::set_skill_enabled( + executable, + fixture.0.join("project"), + fixture.0.join("foreign/SKILL.md"), + false, + &|| false, + ), + ) + .await + .unwrap(); + assert_eq!(rejected, Err("unknown skill")); + assert!(!fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .contains("skills/config/write")); +} + +#[cfg(unix)] +#[test] +fn shared_skill_fixture_preserves_native_scopes_plugins_and_disabled_state() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/skills-management-v1.json" + )) + .unwrap(); + let listing = parse_codex(&fixture["nativeCodex"], Path::new("/fixture/workspace")).unwrap(); + assert_eq!( + serde_json::to_value(listing).unwrap(), + fixture["normalizedCodex"] + ); +} + +#[test] +fn oversized_import_is_bounded_and_never_published() { + let fixture = Sandbox::new(); + let source = fixture.0.join("SKILL.md"); + let file = fs::File::create(&source).unwrap(); + file.set_len(8 * 1024 * 1024 + 1).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + assert!(!fixture.0.join("home/.agents").exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn cancelled_discovery_reaps_the_probe_even_while_initialization_is_stalled() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("stalled-codex"); + let pid_file = fixture.0.join("probe.pid"); + fs::write( + &executable, + format!( + "#!/bin/sh\nprintf '%s' \"$$\" > '{}'\nwhile IFS= read -r line; do :; done\n", + pid_file.display() + ), + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + // The child announces startup before the cancellation check becomes true. + // No real-time sleep coordinates the test; the production poll remains bounded. + let cancelled = || pid_file.exists(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::skills(executable, fixture.0.join("project"), &cancelled), + ) + .await + .unwrap(); + assert_eq!(result, Err("cancelled")); + let pid: i32 = fs::read_to_string(pid_file).unwrap().parse().unwrap(); + // A terminated but unreaped child would still answer kill(pid, 0). + assert_eq!( + unsafe { libc::kill(pid, 0) }, + -1, + "probe must be reaped before discovery returns" + ); +} diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 70a34ebdea..f2b1e4cfce 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -25,6 +25,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" +shell-words = "=1.1.1" toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } toml_edit = "0.25" tree-sitter = "0.25" diff --git a/rust/lithe-core/src/agent/instructions.rs b/rust/lithe-core/src/agent/instructions.rs new file mode 100644 index 0000000000..5e1ebb46d8 --- /dev/null +++ b/rust/lithe-core/src/agent/instructions.rs @@ -0,0 +1,51 @@ +//! Typed global-instruction requests; native file ownership stays in Agent Host. + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_agent_host::instructions::{self, InstructionError, SaveRequest}; +use serde::Deserialize; +use serde_json::Value; + +/// Selects a supported upstream Agent, never an arbitrary filesystem path. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReadRequest { + agent_id: String, +} + +/// Reads the effective native global file without creating directories or files. +pub(crate) fn read(request: ReadRequest) -> Result { + cancellation::check()?; + encode(instructions::read(&request.agent_id)) +} + +/// Atomically saves against the snapshot the user edited; preserves native loading. +pub(crate) fn save(request: SaveRequest) -> Result { + cancellation::check()?; + encode(instructions::save(request)) +} + +fn encode( + result: Result, +) -> Result { + let file = result.map_err(core_error)?; + serde_json::to_value(file) + .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) +} + +/// Reads or synchronizes a platform-owned shared instruction library. +pub(crate) fn shared(request: instructions::shared::Request) -> Result { + cancellation::check()?; + let result = instructions::shared::execute(request, &|| cancellation::check().is_err()) + .map_err(core_error)?; + serde_json::to_value(result) + .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) +} + +fn core_error(error: InstructionError) -> CoreError { + let code = match error.reason { + "unsupported_agent" => ErrorCode::NotSupported, + "file_access" | "unsafe_file" => ErrorCode::PermissionDenied, + _ => ErrorCode::InvalidRequest, + }; + CoreError::new(code, error.message).with_details(error.reason) +} diff --git a/rust/lithe-core/src/agent/mod.rs b/rust/lithe-core/src/agent/mod.rs index 1ccba7d197..352cd8f854 100644 --- a/rust/lithe-core/src/agent/mod.rs +++ b/rust/lithe-core/src/agent/mod.rs @@ -5,6 +5,8 @@ use std::path::PathBuf; +pub(crate) mod instructions; + use serde::Deserialize; use serde_json::Value; @@ -146,3 +148,19 @@ fn emit_progress(progress: install::InstallProgress) { }).to_string()); } } + +/// Execute explicit native skill discovery, installation or visibility configuration. +pub(crate) fn skills(request: lithe_agent_host::skills::Request) -> Result { + use lithe_agent_host::skills::Error; + let result = lithe_agent_host::skills::execute(request, &cancelled); + cancellation::check()?; + result.map_err(|error| { + let code = match &error { + Error::Invalid(_) => ErrorCode::InvalidRequest, + Error::MissingCLI => ErrorCode::RuntimeMissing, + Error::Cancelled => ErrorCode::Cancelled, + Error::Failed(_) => ErrorCode::ProcessFailed, + }; + CoreError::new(code, error.message()) + }) +} diff --git a/rust/lithe-core/src/mcp/featured.json b/rust/lithe-core/src/mcp/featured.json new file mode 100644 index 0000000000..c697d34d1f --- /dev/null +++ b/rust/lithe-core/src/mcp/featured.json @@ -0,0 +1,183 @@ +{ + "reviewed": "2026-10-22", + "servers": [ + { + "server": { + "name": "lithe.featured/context7", + "title": "Context7", + "publisher": "Upstash", + "description": "Current library documentation and code examples for coding agents.", + "websiteUrl": "https://github.com/upstash/context7", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.context7.com/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/playwright", + "title": "Playwright", + "publisher": "Microsoft", + "description": "Open pages, click, type and inspect a browser through its accessibility tree.", + "version": "0.0.83", + "websiteUrl": "https://github.com/microsoft/playwright-mcp", + "packages": [ + { + "registryType": "npm", + "identifier": "@playwright/mcp", + "version": "0.0.83", + "transport": { + "type": "stdio" + } + } + ] + } + }, + { + "server": { + "name": "lithe.featured/chrome-devtools", + "title": "Chrome DevTools", + "publisher": "Google", + "description": "Inspect Chrome pages, console messages, network requests and performance.", + "version": "1.11.0", + "websiteUrl": "https://github.com/ChromeDevTools/chrome-devtools-mcp", + "packages": [ + { + "registryType": "npm", + "identifier": "chrome-devtools-mcp", + "version": "1.11.0", + "transport": { + "type": "stdio" + } + } + ] + } + }, + { + "server": { + "name": "lithe.featured/microsoft-learn", + "title": "Microsoft Learn", + "publisher": "Microsoft", + "description": "Search official Microsoft and Azure documentation and code samples.", + "websiteUrl": "https://github.com/MicrosoftDocs/mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://learn.microsoft.com/api/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/github", + "title": "GitHub", + "publisher": "GitHub", + "description": "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required.", + "websiteUrl": "https://github.com/github/github-mcp-server", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + } + ] + }, + "setup": "bearer-token" + }, + { + "server": { + "name": "lithe.featured/deepwiki", + "title": "DeepWiki", + "publisher": "Cognition", + "description": "Ask questions about public GitHub repositories and read their generated documentation.", + "websiteUrl": "https://docs.devin.ai/work-with-devin/deepwiki-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.deepwiki.com/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/exa", + "title": "Exa", + "publisher": "Exa", + "description": "Search the web and code, and retrieve page content for coding agents.", + "websiteUrl": "https://exa.ai/docs/get-started/exa-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.exa.ai/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/filesystem", + "title": "Filesystem", + "publisher": "Model Context Protocol", + "description": "Read, write and search files only in the directories you allow. Requires Node.js 24 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "version": "2.0.0", + "packages": [ + { + "registryType": "npm", + "identifier": "@modelcontextprotocol/server-filesystem", + "version": "2.0.0", + "transport": { + "type": "stdio" + } + } + ] + }, + "setup": "allowed-directories" + }, + { + "server": { + "name": "lithe.featured/fetch", + "title": "Fetch", + "publisher": "Model Context Protocol", + "description": "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "version": "2026.10.19", + "packages": [ + { + "registryType": "pypi", + "identifier": "mcp-server-fetch", + "version": "2026.10.19", + "transport": { + "type": "stdio" + } + } + ] + } + }, + { + "server": { + "name": "lithe.featured/memory", + "title": "Memory", + "publisher": "Model Context Protocol", + "description": "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "version": "2.0.0", + "packages": [ + { + "registryType": "npm", + "identifier": "@modelcontextprotocol/server-memory", + "version": "2.0.0", + "transport": { + "type": "stdio" + } + } + ] + } + } + ], + "metadata": {} +} diff --git a/rust/lithe-core/src/mcp/mod.rs b/rust/lithe-core/src/mcp/mod.rs index d44eb12bfd..557e219a30 100644 --- a/rust/lithe-core/src/mcp/mod.rs +++ b/rust/lithe-core/src/mcp/mod.rs @@ -8,12 +8,57 @@ mod json_edit; mod model; mod parse; mod plan; +mod registry; mod source_hash; mod toml_edit; +mod tools; pub use model::{McpKeyValue, McpServer, McpTransport}; pub use parse::parse_configuration; pub use plan::plan_write; +pub use registry::{featured_registry, parse_registry}; +pub use tools::{command_line, tool_policy}; + +/// Bridges a user-requested probe to the process/network owner in Agent Host. +pub(crate) fn probe( + request: lithe_agent_host::mcp_probe::Request, +) -> Result { + use crate::protocol::{cancellation, CoreError, ErrorCode}; + cancellation::check()?; + let result = lithe_agent_host::mcp_probe::execute(request, &|| cancellation::check().is_err()); + cancellation::check()?; + result.map_err(|error| { + use lithe_agent_host::mcp_probe::Error; + let (code, message) = match error { + Error::Invalid => ( + ErrorCode::InvalidRequest, + "Check the MCP command, URL and headers.", + ), + Error::Unsupported => ( + ErrorCode::NotSupported, + "Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint.", + ), + Error::Start => ( + ErrorCode::RuntimeMissing, + "Could not start the MCP command. Check its executable and runtime.", + ), + Error::Connection => ( + ErrorCode::ProcessFailed, + "MCP connection failed. Check the server, network and authentication.", + ), + Error::Limit => ( + ErrorCode::InvalidRequest, + "MCP tool metadata exceeded the inspection limit.", + ), + Error::Timeout => ( + ErrorCode::TimedOut, + "MCP detection timed out after 30 seconds.", + ), + Error::Cancelled => (ErrorCode::Cancelled, "MCP detection was cancelled."), + }; + CoreError::new(code, message) + }) +} #[cfg(test)] mod tests; diff --git a/rust/lithe-core/src/mcp/plan.rs b/rust/lithe-core/src/mcp/plan.rs index 759d460527..d610a1634c 100644 --- a/rust/lithe-core/src/mcp/plan.rs +++ b/rust/lithe-core/src/mcp/plan.rs @@ -11,7 +11,7 @@ use super::parse::{ use super::toml_edit; use super::{McpKeyValue, McpServer}; use crate::protocol::{CoreError, ErrorCode}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; #[derive(Deserialize)] @@ -55,6 +55,21 @@ struct WriteRequest { /// Entries the host recorded as written by Lithe. #[serde(default)] owned: Vec, + /// Non-secret native filters retained when this server was removed. + #[serde(default)] + tool_filters: Option, +} + +/// Native Codex restrictions, kept separately from credentials and connection fields. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ToolFilters { + /// Absence differs from an empty allow-list, which allows no tools. + #[serde(skip_serializing_if = "Option::is_none")] + enabled_tools: Option>, + /// Preserve external exclusions as well as those owned by Lithe. + #[serde(skip_serializing_if = "Option::is_none")] + disabled_tools: Option>, } fn invalid_request() -> CoreError { @@ -167,7 +182,16 @@ fn plan_set( }; let next = match request.source.as_str() { "claude" => write_claude(content, project_path, &server.name, server, previous)?, - "codex" => write_codex(content, server, previous)?, + "codex" => write_codex( + content, + server, + previous, + if action == "inserted" { + request.tool_filters.as_ref() + } else { + None + }, + )?, _ => return Err(invalid_request()), }; // Unknown fields survive edits, so hash the actual output rather than the @@ -228,14 +252,50 @@ fn plan_remove( if next == content { return Ok(with_hashes(unchanged(content, "absent", None), current)); } + // Capture before deletion; malformed native restrictions must fail closed + // rather than disappearing when an Agent is switched back on. + let tool_filters = if request.source == "codex" { + codex_tool_filters(content, name)? + } else { + None + }; Ok(json!({ "content": next, "changed": true, "action": "removed", "conflict": Value::Null, + "definitionHash": current.definition_hash, + "toolFilters": tool_filters, })) } +/// Extract only the native tool lists; never archive env, headers or auth values. +fn codex_tool_filters(content: &str, name: &str) -> Result, CoreError> { + let document: toml::Value = toml::from_str(content).map_err(|_| invalid_request())?; + let entry = document + .get("mcp_servers") + .and_then(|v| v.get(name)) + .ok_or_else(invalid_request)?; + let list = |key: &str| -> Result>, CoreError> { + entry + .get(key) + .map(|value| { + value + .as_array() + .ok_or_else(invalid_request)? + .iter() + .map(|v| v.as_str().map(str::to_owned).ok_or_else(invalid_request)) + .collect() + }) + .transpose() + }; + let filters = ToolFilters { + enabled_tools: list("enabled_tools")?, + disabled_tools: list("disabled_tools")?, + }; + Ok((filters.enabled_tools.is_some() || filters.disabled_tools.is_some()).then_some(filters)) +} + fn existing_servers( request: &WriteRequest, content: &str, @@ -407,9 +467,29 @@ fn write_codex( content: &str, server: &McpServer, previous: Option<&str>, + filters: Option<&ToolFilters>, ) -> Result { let mut document = toml_edit::document(content)?; toml_edit::set_server(&mut document, server, previous)?; + if let Some(filters) = filters { + let entry = document + .get_mut("mcp_servers") + .and_then(|v| v.get_mut(&server.name)) + .and_then(::toml_edit::Item::as_table_like_mut) + .ok_or_else(invalid_request)?; + for (key, values) in [ + ("enabled_tools", &filters.enabled_tools), + ("disabled_tools", &filters.disabled_tools), + ] { + if let Some(values) = values { + let mut array = ::toml_edit::Array::new(); + for value in values { + array.push(value.as_str()); + } + entry.insert(key, ::toml_edit::value(array)); + } + } + } Ok(document.to_string()) } diff --git a/rust/lithe-core/src/mcp/registry.rs b/rust/lithe-core/src/mcp/registry.rs new file mode 100644 index 0000000000..b68cae34be --- /dev/null +++ b/rust/lithe-core/src/mcp/registry.rs @@ -0,0 +1,361 @@ +//! Normalize public MCP Registry metadata without fetching or executing servers. + +use super::model::{McpServer, McpTransport}; +use crate::protocol::{CoreError, ErrorCode}; +use serde_json::{json, Value}; + +/// Returns the release-owned catalog without reading files or using the network. +/// Entries pass through the same draft validation as public Registry responses. +pub fn featured_registry() -> Result { + let content = include_str!("featured.json"); + let catalog: Value = serde_json::from_str(content) + .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid featured MCP catalog"))?; + let mut page = parse_registry(json!({"content": content}))?; + if let Some(entries) = page.get_mut("entries").and_then(Value::as_array_mut) { + for entry in entries { + entry["source"] = json!("featured"); + // Only the release-owned catalog may declare an incomplete template. + // Public Registry input must still contain a complete launch recipe. + let setup = catalog["servers"] + .as_array() + .and_then(|servers| { + servers + .iter() + .find(|item| item["server"]["name"] == entry["id"]) + }) + .and_then(|item| item.get("setup")) + .and_then(Value::as_str); + if let Some(setup) = setup { + if !matches!(setup, "bearer-token" | "allowed-directories") + || entry["draft"].is_null() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid featured MCP setup", + )); + } + entry["setup"] = json!(setup); + } + } + } + Ok(page) +} + +/// Reads one bounded v0.1 registry page. Only simple, fully specified launch +/// recipes become drafts; complex inputs remain documentation-only entries. +pub fn parse_registry(payload: Value) -> Result { + let content = payload.get("content").and_then(Value::as_str).unwrap_or(""); + let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP registry response"); + if content.len() > 1024 * 1024 { + return Err(invalid()); + } + let page: Value = serde_json::from_str(content).map_err(|_| invalid())?; + let servers = page + .get("servers") + .and_then(Value::as_array) + .ok_or_else(invalid)?; + if servers.len() > 100 { + return Err(invalid()); + } + let mut entries = Vec::new(); + for item in servers { + let status = item + .pointer("/_meta/io.modelcontextprotocol.registry~1official/status") + .and_then(Value::as_str); + if status.is_some_and(|s| s != "active") { + continue; + } + let Some(server) = item.get("server").filter(|s| s.is_object()) else { + continue; + }; + let Some(name) = server + .get("name") + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && s.len() < 256) + else { + continue; + }; + let title = server.get("title").and_then(Value::as_str).unwrap_or(name); + let mut draft_name: String = name + .rsplit('/') + .next() + .unwrap_or(name) + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || "._-".contains(c) { + c + } else { + '-' + } + }) + .collect(); + if !draft_name.starts_with(|c: char| c.is_ascii_alphanumeric()) { + draft_name.insert_str(0, "mcp-"); + } + let mut draft = None; + if let Some(remotes) = server.get("remotes").and_then(Value::as_array) { + for remote in remotes { + let transport = match remote.get("type").and_then(Value::as_str) { + Some("streamable-http") => McpTransport::Http, + Some("sse") => McpTransport::Sse, + _ => continue, + }; + // Dynamic URLs, headers and authentication require the publisher's + // setup instructions; never turn unresolved placeholders into a config. + if !empty_array(remote, "headers") + || remote + .get("variables") + .is_some_and(|v| v.as_object().is_none_or(|o| !o.is_empty())) + { + continue; + } + if let Some(url) = safe_url(remote.get("url")) { + draft = Some(McpServer::new( + draft_name.clone(), + transport, + None, + vec![], + vec![], + Some(url), + vec![], + )); + break; + } + } + } + if draft.is_none() { + if let Some(packages) = server.get("packages").and_then(Value::as_array) { + for package in packages { + if package.pointer("/transport/type").and_then(Value::as_str) != Some("stdio") + || [ + "runtimeArguments", + "packageArguments", + "environmentVariables", + ] + .iter() + .any(|key| !empty_array(package, key)) + { + continue; + } + let Some(id) = package.get("identifier").and_then(Value::as_str) else { + continue; + }; + let Some(version) = package.get("version").and_then(Value::as_str) else { + continue; + }; + // Dist-tags such as "latest" are mutable and cannot be pinned. + if !safe_package(id) + || !safe_package(version) + || !version.starts_with(|c: char| c.is_ascii_digit()) + { + continue; + } + let (command, args, base) = + match package.get("registryType").and_then(Value::as_str) { + Some("npm") if exact_npm_version(version) => ( + "npx", + vec!["-y".into(), format!("{id}@{version}")], + "https://registry.npmjs.org", + ), + Some("pypi") => { + ("uvx", vec![format!("{id}=={version}")], "https://pypi.org") + } + _ => continue, + }; + // A package on another registry must not be resolved from the + // public default merely because its identifier looks familiar. + if package.get("registryBaseUrl").is_some_and(|url| { + safe_url(Some(url)).is_none_or(|url| url.trim_end_matches('/') != base) + }) { + continue; + } + if package + .get("runtimeHint") + .is_some_and(|hint| hint.as_str() != Some(command)) + { + continue; + } + draft = Some(McpServer::new( + draft_name.clone(), + McpTransport::Stdio, + Some(command.into()), + args, + vec![], + None, + vec![], + )); + break; + } + } + } + entries.push(json!({"id": name, "title": title, "summary": server.get("description").and_then(Value::as_str).unwrap_or(""), + "publisher": server.get("publisher").and_then(Value::as_str), "source": "registry", + "version": server.get("version").and_then(Value::as_str).unwrap_or(""), + "website": safe_url(server.get("websiteUrl")).or_else(|| safe_url(server.pointer("/repository/url"))), "draft": draft})); + } + let cursor = page + .pointer("/metadata/nextCursor") + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && s.len() <= 2048); + Ok(json!({"entries": entries, "nextCursor": cursor})) +} + +fn empty_array(value: &Value, key: &str) -> bool { + value + .get(key) + .is_none_or(|v| v.as_array().is_some_and(Vec::is_empty)) +} + +fn safe_package(value: &str) -> bool { + !value.is_empty() + && value.len() < 256 + && !value.starts_with('-') + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"@/._-+".contains(&c)) +} + +fn exact_npm_version(version: &str) -> bool { + let components: Vec<_> = version + .split(['-', '+']) + .next() + .unwrap_or("") + .split('.') + .collect(); + components.len() == 3 + && components + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + && version + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-+".contains(&b)) +} + +fn safe_url(value: Option<&Value>) -> Option { + let raw = value?.as_str()?; + if raw.contains(['{', '}']) { + return None; + } + let url = url::Url::parse(raw).ok()?; + (url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none()) + .then(|| url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn featured_catalog_is_available_offline_with_pinned_reviewable_drafts() { + let page = featured_registry().unwrap(); + let entries = page["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 10); + assert!(page["nextCursor"].is_null()); + assert!(entries + .iter() + .all(|e| e["source"] == "featured" && e["draft"].is_object())); + assert_eq!(entries[0]["publisher"], "Upstash"); + assert_eq!( + entries[1]["draft"]["args"], + json!(["-y", "@playwright/mcp@0.0.83"]) + ); + assert_eq!( + entries[2]["draft"]["args"], + json!(["-y", "chrome-devtools-mcp@1.11.0"]) + ); + let find = |name: &str| entries.iter().find(|e| e["draft"]["name"] == name).unwrap(); + assert_eq!(find("github")["setup"], "bearer-token"); + assert_eq!( + find("github")["draft"]["url"], + "https://api.githubcopilot.com/mcp/" + ); + assert_eq!( + find("deepwiki")["draft"]["url"], + "https://mcp.deepwiki.com/mcp" + ); + assert_eq!(find("exa")["draft"]["url"], "https://mcp.exa.ai/mcp"); + assert_eq!(find("filesystem")["setup"], "allowed-directories"); + assert_eq!( + find("filesystem")["draft"]["args"], + json!(["-y", "@modelcontextprotocol/server-filesystem@2.0.0"]) + ); + assert_eq!( + find("memory")["draft"]["args"], + json!(["-y", "@modelcontextprotocol/server-memory@2.0.0"]) + ); + assert_eq!( + find("fetch")["draft"]["args"], + json!(["mcp-server-fetch==2026.10.19"]) + ); + } + + #[test] + fn public_registry_cannot_declare_trusted_featured_setup() { + let page = parse_registry(json!({"content": json!({"servers": [{ + "setup": "bearer-token", "server": {"name": "lithe.featured/github", + "setup": "bearer-token", "remotes": [{"type": "streamable-http", "url": "https://example.invalid/mcp"}]} + }]}).to_string()})).unwrap(); + assert_eq!(page["entries"][0]["source"], "registry"); + assert!(page["entries"][0].get("setup").is_none()); + } + + #[test] + fn registry_page_keeps_versions_cursor_and_only_complete_drafts() { + let content = include_str!("../../../../shared/contracts/mcp-registry-page.json"); + let page = parse_registry(json!({"content": content})).unwrap(); + assert_eq!(page["entries"].as_array().unwrap().len(), 3); + assert_eq!(page["nextCursor"], "opaque/+ cursor?"); + assert_eq!(page["entries"][0]["draft"]["transport"], "http"); + assert_eq!( + page["entries"][1]["draft"]["args"], + json!(["-y", "@example/browser@2.0.0"]) + ); + assert!(page["entries"][2]["draft"].is_null()); + } + + #[test] + fn untrusted_links_dynamic_urls_and_package_options_are_not_launch_recipes() { + for url in [ + "javascript:alert(1)", + "https://user:secret@example.invalid/mcp", + "https://example.invalid/{tenant}", + ] { + let page = parse_registry(json!({"content": json!({"servers": [{"server": { + "name": "io.example/untrusted", "websiteUrl": url, + "remotes": [{"type": "streamable-http", "url": url}], + "packages": [{"registryType":"npm", "identifier":"--eval", "version":"1", "transport":{"type":"stdio"}}] + }}]}).to_string()})).unwrap(); + assert!(page["entries"][0]["draft"].is_null()); + assert!(page["entries"][0]["website"].is_null()); + } + for (version, hint, base) in [ + ("latest", "npx", "https://registry.npmjs.org"), + ("1", "npx", "https://registry.npmjs.org"), + ("1.0.0", "node", "https://registry.npmjs.org"), + ("1.0.0", "npx", "https://example.invalid/npm"), + ] { + let page = parse_registry(json!({"content": json!({"servers": [{"server": { + "name": "io.example/needs-setup", + "packages": [{"registryType":"npm", "identifier":"example", "version":version, + "runtimeHint":hint, "registryBaseUrl":base, "transport":{"type":"stdio"}}] + }}]}).to_string()})) + .unwrap(); + assert!(page["entries"][0]["draft"].is_null()); + } + } + + #[test] + fn malformed_and_oversized_pages_fail_without_echoing_contents() { + for content in [ + "secret invalid json".to_string(), + "x".repeat(1024 * 1024 + 1), + "{}".to_string(), + ] { + let error = parse_registry(json!({"content": content})).unwrap_err(); + assert!(!error.message.contains("secret")); + } + } +} diff --git a/rust/lithe-core/src/mcp/tests.rs b/rust/lithe-core/src/mcp/tests.rs index 5dd12651b1..7aaa7089f9 100644 --- a/rust/lithe-core/src/mcp/tests.rs +++ b/rust/lithe-core/src/mcp/tests.rs @@ -46,6 +46,61 @@ fn owned_entry(parsed: &Value, name: &str) -> Value { }) } +#[test] +fn removed_codex_filters_restore_without_credentials_or_allowlist_widening() { + for allowed in ["[]", "['read', 'write']"] { + let content = format!("model = 'keep'\n[mcp_servers.alpha]\ncommand = 'echo'\nenabled_tools = {allowed}\ndisabled_tools = ['external', 'write']\nenv = {{ TOKEN = 'secret' }}\n"); + let parsed = parse("codex", "global", &content, None); + let removed = plan(json!({"source":"codex","scope":"global","content":content, + "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); + assert_eq!(removed["action"], "removed"); + let filters = &removed["toolFilters"]; + assert_eq!(filters["disabledTools"], json!(["external", "write"])); + assert!(!filters.to_string().contains("secret")); + let restored = plan( + json!({"source":"codex","scope":"global","content":removed["content"], + "operation":"set","server":parsed["servers"][0],"owned":[],"toolFilters":filters}), + ); + let next: toml::Value = toml::from_str(restored["content"].as_str().unwrap()).unwrap(); + let original: toml::Value = toml::from_str(&content).unwrap(); + assert_eq!( + next["mcp_servers"]["alpha"], + original["mcp_servers"]["alpha"] + ); + assert_eq!(next["model"].as_str(), Some("keep")); + let reparsed = parse( + "codex", + "global", + restored["content"].as_str().unwrap(), + None, + ); + assert_eq!(restored["sourceHash"], source_hash(&reparsed, "alpha")); + } +} + +#[test] +fn retained_codex_filters_never_replace_live_native_policy() { + let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['external']\nenabled_tools = []\n"; + let parsed = parse("codex", "global", content, None); + let restored = plan(json!({"source":"codex","scope":"global","content":content, + "operation":"set","server":parsed["servers"][0],"owned":[owned_entry(&parsed,"alpha")], + "toolFilters":{"disabledTools":[],"enabledTools":["write"]}})); + assert_eq!(restored["content"], content); + assert_eq!(restored["changed"], false); +} + +#[test] +fn malformed_native_tool_lists_cannot_be_deleted_and_forgotten() { + let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['read', 1]\n"; + let parsed = parse("codex", "global", content, None); + let result = plan_write(json!({"source":"codex","scope":"global","content":content, + "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); + assert!(matches!( + result.unwrap_err().code, + ErrorCode::InvalidRequest + )); +} + fn plan(request: Value) -> Value { plan_write(request).expect("write plan should succeed") } diff --git a/rust/lithe-core/src/mcp/tools.rs b/rust/lithe-core/src/mcp/tools.rs new file mode 100644 index 0000000000..b239797cc9 --- /dev/null +++ b/rust/lithe-core/src/mcp/tools.rs @@ -0,0 +1,254 @@ +//! Deterministic command-line editing and native per-tool exclusion planning. +//! Hosts own file snapshots and remember only the exact exclusions they added. + +use super::{json_edit::JsonDocument, parse::MAX_CONFIGURATION_BYTES}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +fn invalid() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP tool configuration") +} + +/// Parses or formats one terminal-like argv projection without invoking a shell. +pub fn command_line(payload: Value) -> Result { + let line = if payload["action"] == "format" { + let command = payload["command"].as_str().ok_or_else(invalid)?; + let args = strings(payload.get("args"))?; + if command.is_empty() && args.is_empty() { + return Ok(json!({"line":""})); + } + if command.len() + args.iter().map(String::len).sum::() > MAX_CONFIGURATION_BYTES { + return Err(invalid()); + } + let all = std::iter::once(command).chain(args.iter().map(String::as_str)); + return Ok(json!({"line": shell_words::join(all)})); + } else if payload["action"] == "parse" { + payload["line"].as_str().ok_or_else(invalid)? + } else { + return Err(invalid()); + }; + if line.len() > MAX_CONFIGURATION_BYTES { + return Err(invalid()); + } + let parts = shell_words::split(line).map_err(|_| invalid())?; + Ok( + json!({"command": parts.first().cloned().unwrap_or_default(), "args": parts.into_iter().skip(1).collect::>() }), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PolicyRequest { + /// Native owner, `codex` or `claude`. + source: String, + /// Current bounded UTF-8 text from the platform snapshot. + content: String, + /// Native MCP entry name. + server: String, + /// Names reported by this server, never a wildcard expression. + tools: Vec, + /// Exclusions previously added by this Lithe library for this native path. + #[serde(default)] + owned: Vec, + /// Optional explicit one-tool mutation; absence reads policy only. + tool: Option, + enabled: Option, +} + +/// Returns effective local exclusions and an optional format-preserving edit. +/// External exact/wildcard denies and allow-lists are never relaxed here. +pub fn tool_policy(payload: Value) -> Result { + let r: PolicyRequest = serde_json::from_value(payload).map_err(|_| invalid())?; + if r.content.len() > MAX_CONFIGURATION_BYTES + || r.tools.len() > 1000 + || r.owned.len() > 1000 + || !valid_name(&r.server) + || r.tools.iter().chain(&r.owned).any(|s| !valid_name(s)) + || r.tool.as_ref().is_some_and(|s| !r.tools.contains(s)) + || r.tool.is_some() != r.enabled.is_some() + { + return Err(invalid()); + } + let (mut denied, allow) = match r.source.as_str() { + "codex" => { + let doc: toml::Value = toml::from_str(&r.content).map_err(|_| invalid())?; + let entry = doc + .get("mcp_servers") + .and_then(|v| v.get(&r.server)) + .ok_or_else(invalid)?; + let list = |key: &str| -> Result, CoreError> { + match entry.get(key) { + None => Ok(vec![]), + Some(v) => v + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) + .collect(), + } + }; + ( + list("disabled_tools")?, + if entry.get("enabled_tools").is_some() { + Some(list("enabled_tools")?) + } else { + None + }, + ) + } + "claude" => { + let doc: Value = serde_json::from_str(if r.content.is_empty() { + "{}" + } else { + &r.content + }) + .map_err(|_| invalid())?; + if !doc.is_object() || doc.get("permissions").is_some_and(|v| !v.is_object()) { + return Err(invalid()); + } + (strings(doc.pointer("/permissions/deny"))?, None) + } + _ => return Err(invalid()), + }; + let rule = |tool: &str| -> String { + if r.source == "claude" { + format!("mcp__{}__{}", normalize(&r.server), normalize(tool)) + } else { + tool.to_owned() + } + }; + if r.source == "claude" + && r.tool.as_ref().is_some_and(|tool| { + r.tools + .iter() + .any(|other| other != tool && normalize(other) == normalize(tool)) + }) + { + return Err(invalid()); + } + let is_blocked = |tool: &str, list: &[String]| { + if r.source == "codex" { + list.iter().any(|s| s == tool) + } else { + let exact = rule(tool); + list.iter() + .any(|s| wildcard(s, &exact) || s == &format!("mcp__{}", normalize(&r.server))) + } + }; + let mut owned = r.owned.clone(); + let mut changed = false; + if let (Some(tool), Some(enabled)) = (&r.tool, r.enabled) { + let exact = rule(tool); + if enabled { + if allow.as_ref().is_some_and(|a| !a.contains(tool)) + || (is_blocked(tool, &denied) && !owned.contains(tool)) + { + return Err(CoreError::new(ErrorCode::PermissionDenied, "This tool is restricted outside Lithe. Edit the native Agent policy to enable it.")); + } + let length = denied.len(); + denied.retain(|s| s != &exact); + changed = length != denied.len(); + owned.retain(|s| s != tool); + } else if !is_blocked(tool, &denied) { + denied.push(exact); + if !owned.contains(tool) { + owned.push(tool.clone()); + } + changed = true; + } + } + let content = if changed { + if r.source == "codex" { + let mut doc: toml_edit::DocumentMut = r.content.parse().map_err(|_| invalid())?; + let entry = doc + .get_mut("mcp_servers") + .and_then(|v| v.get_mut(&r.server)) + .and_then(toml_edit::Item::as_table_like_mut) + .ok_or_else(invalid)?; + let mut array = toml_edit::Array::new(); + for name in &denied { + array.push(name.as_str()); + } + entry.insert("disabled_tools", toml_edit::value(array)); + doc.to_string() + } else { + let mut doc = JsonDocument::parse(if r.content.is_empty() { + "{}" + } else { + &r.content + })?; + let object = doc.ensure_object(&["permissions"])?; + doc.set_member( + object, + "deny", + &serde_json::to_string(&denied).map_err(|_| invalid())?, + )?; + doc.into_string()? + } + } else { + r.content.clone() + }; + let tools: Vec<_> = r.tools.iter().map(|tool| { + let blocked = is_blocked(tool, &denied); + let outside_allow = allow.as_ref().is_some_and(|a| !a.contains(tool)); + // An owned exact rule does not authorize removing a broader user deny. + let other_deny = denied.iter().any(|s| s != &rule(tool) && is_blocked(tool, std::slice::from_ref(s))); + let ambiguous = r.source == "claude" && r.tools.iter().any(|other| other != tool && normalize(other) == normalize(tool)); + json!({"name":tool,"enabled":!blocked && !outside_allow,"editable":!ambiguous && !outside_allow && !other_deny && (!blocked || owned.contains(tool))}) + }).collect(); + let has_restrictions = if r.source == "claude" { + denied + .iter() + .any(|s| s.starts_with("mcp") || s.starts_with('*')) + } else { + !denied.is_empty() || allow.is_some() + }; + Ok( + json!({"content":content,"changed":changed,"owned":owned,"tools":tools,"hasRestrictions":has_restrictions}), + ) +} + +fn strings(value: Option<&Value>) -> Result, CoreError> { + match value { + None => Ok(vec![]), + Some(v) => v + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) + .collect(), + } +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 256 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c)) +} + +fn normalize(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || "_-".contains(c) { + c + } else { + '_' + } + }) + .collect() +} + +fn wildcard(pattern: &str, value: &str) -> bool { + let pattern = pattern + .split('*') + .map(regex::escape) + .collect::>() + .join(".*"); + regex::Regex::new(&format!("^{pattern}$")).is_ok_and(|re| re.is_match(value)) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/mcp/tools/tests.rs b/rust/lithe-core/src/mcp/tools/tests.rs new file mode 100644 index 0000000000..e336b90ffb --- /dev/null +++ b/rust/lithe-core/src/mcp/tools/tests.rs @@ -0,0 +1,114 @@ +//! Per-tool policy and command projections protect native ownership boundaries. +use super::*; + +fn request(source: &str, content: &str) -> Value { + json!({"source":source,"content":content,"server":"docs","tools":["read","write"],"owned":[]}) +} + +#[test] +fn quoted_command_line_preserves_every_argument_without_shell_execution() { + let args = vec![ + "", + "a b", + "it's quoted", + "line\nnext", + "$(touch forbidden)", + "a;rm -f file", + "é", + ]; + let formatted = + command_line(json!({"action":"format","command":"/fake tools/server","args":args})) + .unwrap(); + let parsed = command_line(json!({"action":"parse","line":formatted["line"]})).unwrap(); + assert_eq!(parsed["command"], "/fake tools/server"); + assert_eq!(parsed["args"], json!(args)); + assert!(command_line(json!({"action":"parse","line":"npx 'unterminated"})).is_err()); +} + +#[test] +fn codex_only_removes_own_deny_and_retains_unknown_settings() { + let content = "# user formatting\n[mcp_servers.docs]\ncommand = 'node'\nargs = ['server']\ncustom = 9\ndisabled_tools = ['write']\n"; + let mut r = request("codex", content); + let read = tool_policy(r.clone()).unwrap(); + assert_eq!( + read["tools"][1], + json!({"name":"write","enabled":false,"editable":false}) + ); + r["tool"] = json!("write"); + r["enabled"] = json!(true); + assert!(tool_policy(r.clone()).is_err()); + r["tool"] = json!("read"); + r["enabled"] = json!(false); + let off = tool_policy(r.clone()).unwrap(); + assert!(off["content"].as_str().unwrap().contains("custom = 9")); + assert!(off["content"] + .as_str() + .unwrap() + .starts_with("# user formatting\n")); + r["content"] = off["content"].clone(); + r["owned"] = off["owned"].clone(); + r["enabled"] = json!(true); + let on = tool_policy(r).unwrap(); + let doc: toml::Value = toml::from_str(on["content"].as_str().unwrap()).unwrap(); + assert_eq!( + doc["mcp_servers"]["docs"]["disabled_tools"] + .as_array() + .unwrap()[0] + .as_str(), + Some("write") + ); + assert_eq!(on["tools"][0]["enabled"], true); +} + +#[test] +fn claude_exact_and_wildcard_denies_are_not_relaxed() { + let content = "{\n \"user\": {\"keep\": [1, 2]},\n \"permissions\": {\"deny\": [\"Bash(rm *)\", \"mcp__docs__write\"],\"allow\": [\"Read\"]}\n}"; + let mut r = request("claude", content); + r["tool"] = json!("read"); + r["enabled"] = json!(false); + let off = tool_policy(r.clone()).unwrap(); + assert!(off["content"] + .as_str() + .unwrap() + .contains("\"user\": {\"keep\": [1, 2]}")); + r["content"] = off["content"].clone(); + r["owned"] = off["owned"].clone(); + r["enabled"] = json!(true); + let on = tool_policy(r).unwrap(); + assert_eq!( + serde_json::from_str::(on["content"].as_str().unwrap()).unwrap()["permissions"] + ["deny"], + json!(["Bash(rm *)", "mcp__docs__write"]) + ); + let mut broad = request( + "claude", + r#"{"permissions":{"deny":["mcp__d*__*write", "mcp__docs__read"]}}"#, + ); + broad["owned"] = json!(["write"]); + let state = tool_policy(broad).unwrap(); + assert_eq!(state["tools"][1]["editable"], false); + assert_eq!(state["tools"][1]["enabled"], false); +} + +#[test] +fn allow_lists_ambiguous_names_and_malformed_policy_fail_closed() { + let r = request( + "codex", + "[mcp_servers.docs]\ncommand='node'\nenabled_tools=['write']\n", + ); + assert_eq!(tool_policy(r).unwrap()["tools"][0]["editable"], false); + let mut r = request("claude", "{}"); + r["tools"] = json!(["read.one", "read_one"]); + assert!(tool_policy(r).unwrap()["tools"] + .as_array() + .unwrap() + .iter() + .all(|t| t["editable"] == false)); + for content in [r#"{"permissions":[]}"#, r#"{"permissions":{"deny":[1]}}"#] { + assert!(tool_policy(request("claude", content)).is_err()); + } + let mut r = request("claude", "{}"); + r["tool"] = json!("*"); + r["enabled"] = json!(false); + assert!(tool_policy(r).is_err()); +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 5a76d00f19..094f9d984e 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -38,6 +38,14 @@ pub enum CoreCommand { Ping, /// Detects Node.js and npm and lists catalog agent installs (`agent.status`). AgentStatus, + /// Reads the effective native global instruction file (`agent.instructions.read`). + AgentInstructionsRead, + /// Saves a guarded native global instruction draft (`agent.instructions.save`). + AgentInstructionsSave, + /// Synchronizes a marked shared block in native user files (`agent.instructions.shared`). + AgentInstructionsShared, + /// Lists native skills or creates/imports one explicitly (`agent.skills`). + AgentSkills, /// Installs a catalog ACP adapter with the user's npm (`agent.install`). AgentInstall, /// Removes a Lithe-managed ACP adapter install (`agent.uninstall`). @@ -48,8 +56,18 @@ pub enum CoreCommand { AgentParseProviderConfiguration, /// Parses one Agent MCP configuration file into normalized servers (`mcp.parseConfiguration`). McpParseConfiguration, + /// Normalizes a public Registry response into discoverable server drafts (`mcp.parseRegistry`). + McpParseRegistry, + /// Returns the bundled, offline discovery catalog (`mcp.featuredRegistry`). + McpFeaturedRegistry, /// Plans a format-preserving MCP entry write or removal (`mcp.planWrite`). McpPlanWrite, + /// Probes an explicitly selected server without calling its tools. + McpProbe, + /// Reads or plans native per-tool exclusion lists. + McpToolPolicy, + /// Round-trips a quoted command line into exact argv boundaries. + McpCommandLine, /// Validates and normalizes a declarative plugin manifest (`plugins.declarative.validate`). PluginsDeclarativeValidate, /// Expands a declarative plugin's run template (`plugins.declarative.runPlan`). @@ -420,12 +438,21 @@ impl CoreCommand { "core.ping" => Some(Self::Ping), "ideHost.control" => Some(Self::IdeHostControl), "agent.status" => Some(Self::AgentStatus), + "agent.instructions.read" => Some(Self::AgentInstructionsRead), + "agent.instructions.save" => Some(Self::AgentInstructionsSave), + "agent.instructions.shared" => Some(Self::AgentInstructionsShared), + "agent.skills" => Some(Self::AgentSkills), "agent.install" => Some(Self::AgentInstall), "agent.uninstall" => Some(Self::AgentUninstall), "agent.installCli" => Some(Self::AgentInstallCli), "agent.parseProviderConfiguration" => Some(Self::AgentParseProviderConfiguration), "mcp.parseConfiguration" => Some(Self::McpParseConfiguration), + "mcp.parseRegistry" => Some(Self::McpParseRegistry), + "mcp.featuredRegistry" => Some(Self::McpFeaturedRegistry), "mcp.planWrite" => Some(Self::McpPlanWrite), + "mcp.probe" => Some(Self::McpProbe), + "mcp.toolPolicy" => Some(Self::McpToolPolicy), + "mcp.commandLine" => Some(Self::McpCommandLine), "plugins.declarative.validate" => Some(Self::PluginsDeclarativeValidate), "plugins.declarative.runPlan" => Some(Self::PluginsDeclarativeRunPlan), "plugins.declarative.discoverTests" => Some(Self::PluginsDeclarativeDiscoverTests), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 24c0fb1c3a..cfe8f743c4 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -139,6 +139,16 @@ fn execute(request: &str) -> CoreResponse { }), ), CoreCommand::AgentStatus => agent_response(id, parsed.payload, crate::agent::status), + CoreCommand::AgentInstructionsRead => { + agent_response(id, parsed.payload, crate::agent::instructions::read) + } + CoreCommand::AgentInstructionsSave => { + agent_response(id, parsed.payload, crate::agent::instructions::save) + } + CoreCommand::AgentInstructionsShared => { + agent_response(id, parsed.payload, crate::agent::instructions::shared) + } + CoreCommand::AgentSkills => agent_response(id, parsed.payload, crate::agent::skills), CoreCommand::AgentParseProviderConfiguration => { match crate::ai::parse_provider_configuration(parsed.payload) { Ok(data) => CoreResponse::success(id, data), @@ -151,6 +161,23 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::McpParseRegistry => match crate::mcp::parse_registry(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::McpFeaturedRegistry => match crate::mcp::featured_registry() { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::McpProbe => agent_response(id, parsed.payload, crate::mcp::probe), + CoreCommand::McpToolPolicy => match crate::mcp::tool_policy(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::McpCommandLine => match crate::mcp::command_line(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, CoreCommand::McpPlanWrite => match crate::mcp::plan_write(parsed.payload) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs index 155e3e952a..251ac2ef83 100644 --- a/rust/lithe-core/src/tests/agent.rs +++ b/rust/lithe-core/src/tests/agent.rs @@ -1,6 +1,49 @@ use crate::execute_json; use serde_json::{json, Value}; +#[test] +fn global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/agent-instructions-v1.json" + )) + .unwrap(); + for key in ["readRequest", "saveRequest"] { + let command = fixture[key]["command"].as_str().unwrap(); + assert!(crate::protocol::CoreCommand::parse(command).is_some()); + let mut payload = fixture[key]["payload"].clone(); + payload["agentId"] = json!("unknown-agent"); + let response = run(command, payload); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "not_supported"); + assert_eq!(response["error"]["details"], "unsupported_agent"); + } + let response = run( + "agent.instructions.save", + json!({"agentId":"codex-acp","content":"no guard"}), + ); + assert_eq!(response["error"]["code"], "invalid_request"); + let response = run( + "agent.instructions.read", + json!({"agentId":"codex-acp","path":"/untrusted/arbitrary.md"}), + ); + assert_eq!(response["error"]["code"], "invalid_request"); +} + +#[test] +fn shared_instruction_requests_have_strict_typed_dispatch_without_arbitrary_native_paths() { + assert!(crate::protocol::CoreCommand::parse("agent.instructions.shared").is_some()); + for payload in [ + json!({"dataDirectory":"relative", "action":"read"}), + json!({"dataDirectory":"/fixture", "action":"unknown"}), + json!({"dataDirectory":"/fixture", "action":"read", "path":"/arbitrary"}), + json!({"dataDirectory":"/fixture", "action":"setEnabled", "agentId":"codex-acp", "enabled":true}), + ] { + let response = run("agent.instructions.shared", payload); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} + fn run(command: &str, payload: Value) -> Value { let request = json!({ "id": "agent-test", "command": command, "payload": payload }); serde_json::from_str(&execute_json(&request.to_string())).expect("core response JSON") diff --git a/rust/lithe-core/tests/git_path_roundtrip.rs b/rust/lithe-core/tests/git_path_roundtrip.rs index 58bfa986c9..9b8c9e1c48 100644 --- a/rust/lithe-core/tests/git_path_roundtrip.rs +++ b/rust/lithe-core/tests/git_path_roundtrip.rs @@ -32,9 +32,11 @@ impl Drop for Fixture { } fn run(command: &mut Command) -> String { - // Git/Node startup can exceed five seconds on Windows CI. Keep a local - // deadline inside the timing harness's 15-second case watchdog. - let deadline = Instant::now() + Duration::from_secs(10); + run_with_timeout(command, Duration::from_secs(10)) +} + +fn run_with_timeout(command: &mut Command, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; let result = lithe_git_host::run( command, None, @@ -44,8 +46,10 @@ fn run(command: &mut Command) -> String { ); assert!( result.failure.is_none(), - "{command:?}: {:?}", - result.failure + "{command:?} within {timeout:?}: {:?}; status: {:?}; stderr: {}", + result.failure, + result.status, + String::from_utf8_lossy(&result.stderr) ); assert!( result.status.is_some_and(|status| status.success()), @@ -88,11 +92,15 @@ fn frontend_path(path: &str) -> String { .join("../../windows/tauri/src/features/git/api/git-repository-path.ts"); // Node is already required by both platform timing harnesses. Import the // production TypeScript function rather than duplicating it in Rust. - run(Command::new("node").args([ + // Windows CI reached the old 10s deadline in this Node/TypeScript process. + // Keep Git and Core request deadlines unchanged; + // only this frontend subprocess gets 20s within the scoped 60s case budget. + let timeout = Duration::from_secs(if cfg!(windows) { 20 } else { 10 }); + run_with_timeout(Command::new("node").args([ "--experimental-strip-types", "--input-type=module", "-e", "import { pathToFileURL } from 'node:url'; const { normalizeRepositoryPath } = await import(pathToFileURL(process.argv[1]).href); console.log(normalizeRepositoryPath(process.argv[2]));", module.to_str().unwrap(), path, - ])) + ]), timeout) } fn initialize(root: &Path) -> String { diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index cc5abcbccf..a68291dd88 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -387,6 +387,12 @@ async function main() { // Includes agent-ui-test-evidence: fixture screenshots belong to one test // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. + // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. + // Includes mcp-tool-probe-state: SDK sessions, native tool exclusions and + // retained Codex tool lists and in-memory snapshots belong to one user + // profile/page, never build reuse (including after server removal). + // Includes mcp-registry-metadata: responses have no copy stage; featured.json + // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging // and active/previous approvals are runtime-owned. Reject before resolving worktrees diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index d8b29e875b..2ff2ac5b5e 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -230,13 +230,34 @@ try { await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); - for (const id of ["official-plugin-packages", "plugin-marketplace-state"]) { + for (const id of ["official-plugin-packages", "plugin-marketplace-state", "mcp-registry-metadata", "mcp-tool-probe-state", "agent-instruction-library-state"]) { assert.ok(!listed.stdout.includes(id)); const refused = reuse(["--resource", id]); assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), new RegExp(`${id}.*cannot be reused`)); } }); + await test("retained MCP filters and native review screenshots remain user/run state", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const policies = registry.excludedResources.find(item => item.id === "mcp-tool-probe-state"); + assert.ok(policies.locations.some(location => location.includes("settings.mcpServerRegistrations") && location.endsWith(".toolFilters"))); + const evidence = registry.excludedResources.find(item => item.id === "agent-ui-test-evidence"); + assert.ok(evidence.locations.includes(".artifacts/test-stability/{mcp-ui,instructions-ui}/*.png")); + for (const resource of [policies, evidence]) { + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), new RegExp(`${resource.id}.*cannot be reused`)); + } + }); + await test("featured catalog and bundled MCP marks follow Git source instead of resource reuse", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const row = registry.excludedResources.find(item => item.id === "mcp-registry-metadata"); + assert.ok(row.locations.includes("macos/Sources/Lithe/Resources/McpIcons")); + assert.ok(row.locations.includes("rust/lithe-core/src/mcp/featured.json")); + const refused = reuse(["--resource", row.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /mcp-registry-metadata.*cannot be reused/); + }); await test("local disk snapshots and approvals are rejected before any worktree access", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "plugin-disk-install-state"); diff --git a/scripts/verify-rust-core-crates.sh b/scripts/verify-rust-core-crates.sh index 7cf4806ed8..9bfe4b8da0 100755 --- a/scripts/verify-rust-core-crates.sh +++ b/scripts/verify-rust-core-crates.sh @@ -21,9 +21,10 @@ node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-git-host \ --suite-timeout-ms 120000 \ --report .artifacts/test-stability/git-host-rust.json +# Agent Host's ACP/MCP dependencies need the runner's default cold-build budget; +# its suite deadline includes compilation, while each test remains capped at 15s. node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-agent-host \ - --suite-timeout-ms 120000 \ --report .artifacts/test-stability/agent-host-rust.json node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-ide-host \ diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 880bf8086e..982183b2c7 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -36,7 +36,10 @@ { "id": "agent-edit-and-paste-state", "reusable": false, - "locations": ["/agent/pasted-images/pasted-.", "/.lithe-agent-recovery---"], + "locations": [ + "/agent/pasted-images/pasted-.", + "/.lithe-agent-recovery---" + ], "identity": "One user attachment or guarded revert operation owns these mutable bytes. No reusable version/platform/architecture/toolchain identity stamp exists.", "reason": "The Windows attachment adapter writes only to platform application cache; abandoned batches delete their own files, accepted references remain available to history. Revert stages a same-directory file until Windows recycling succeeds, restoring on failure without overwriting a recreated destination. A conflicting restoration reports the recovery location for manual action. Neither resource may enter any worktree copy stage or installation directory; signatures and update baselines remain unchanged." }, @@ -288,6 +291,7 @@ "reusable": false, "locations": [ ".artifacts/test-stability/*.png", + ".artifacts/test-stability/{mcp-ui,instructions-ui}/*.png", ".artifacts//screenshots/*.png", "/lithe-localized-logging.*/Lithe Logging UI.app/" ], @@ -306,6 +310,41 @@ ], "identity": "Mutable distribution-, process-session- and operation-owned diagnostic evidence. No reliable version/platform/architecture/toolchain identity stamp; immutable previews contain redacted bytes for one explicit confirmation only.", "reason": "Native adapters create module logs in user log storage, expire at 30 days and enforce disk budgets. macOS owns one cache staging directory until export/cancel; Windows keeps at most four five-minute previews only in memory and atomically publishes a destination-owned temporary ZIP. Never reuse, copy or share at any worktree stage, and never write into signed packages or installed resources." + }, + { + "id": "mcp-registry-metadata", + "reusable": false, + "locations": [ + "/MCPRegistry/search-results", + "rust/lithe-core/src/mcp/featured.json", + "macos/Sources/Lithe/Resources/McpIcons" + ], + "identity": "Fetched public directory responses are bounded to 1 MiB, normalized by Rust Core and owned by one cancellable search; there is no persistent cache or build identity stamp. The separate featured.json catalog is Git-owned compile-time source, not a reusable generated or downloaded resource. Bundled McpIcons are Git source assets with reviewed source URLs and SHA-256 in NOTICE.json, not generated cache entries; they have no reusable version/platform/architecture/toolchain stamp.", + "reason": "The macOS ephemeral URLSessions keep fetched directory metadata in memory only. Never copy, bundle or share fetched search responses between worktrees; query, pagination and the explicitly selected system/direct route belong to the current page. The static rust/lithe-core/src/mcp/featured.json catalog follows the source checkout and is compiled read-only into Core; never copy it from a runtime cache. McpIcons are copied from the same checkout only during SwiftPM packaging, read-only at runtime, and never extracted from another worktree or installed app. No server packages or images are downloaded at runtime, and no runtime writes touch the installed bundle or Sparkle delta baseline." + }, + { + "id": "agent-instruction-library-state", + "reusable": false, + "locations": [ + "/Lithe/AgentInstructions/Shared.md", + "/Lithe/AgentInstructions/.sync.lock", + "/Lithe/AgentInstructions/.lithe-instructions-.tmp", + "/{AGENTS.md,AGENTS.override.md,CLAUDE.md}" + ], + "identity": "Mutable user-authored instruction text, native snapshots and nonblocking synchronization locks; no version/platform/architecture/toolchain build identity.", + "reason": "The platform storage adapter supplies the writable application-support root. Agent Host owns bounded UTF-8 reads and guarded atomic saves; only explicit saves and switches write. Temporary siblings are removed after success/failure, the empty lock stays with the user library, and native files retain upstream ownership outside the Lithe block. Never copy, bundle, share or validate these as worktree build resources; installed packages and Sparkle baselines remain read-only." + }, + { + "id": "mcp-tool-probe-state", + "reusable": false, + "locations": [ + "/MCPTools/{connections,tool-descriptions,policy-snapshots}", + "/settings.mcpToolExclusions..", + "/settings.mcpServerRegistrations...toolFilters", + "/{config.toml,settings.json}" + ], + "identity": "User-requested, bounded SDK connections and mutable native exclusion rules. No generated build identity, version/platform/architecture/toolchain stamp or reusable artifact.", + "reason": "Probes run only after an explicit action, list metadata without tool calls, and cancel on page exit. Process trees and HTTP sessions are cleaned up; temporary policy snapshots stay in memory. Native rules use the existing platform atomic writer and Application Support backups. User preferences remember added exclusion ownership and retained Codex allow/deny tool names after removal, never credentials. Retained filters survive page/process exit until re-registration and do not prove live source ownership. No copy stage, runtime bundle writes, or cross-worktree transfer is allowed. Upstream package caches created by an explicitly run command remain owned by that runtime, not Lithe build resources." } ] } diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index ca5be39943..3e61c7ec56 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -20,6 +20,31 @@ verification scripts are the executable source of boundary checks. ## Feature Contracts +Global Agent instructions edit each upstream Agent's native user file, independently +of the current project and provider. Opening settings is read-only. Codex uses +`CODEX_HOME` (otherwise the user home `.codex`) and its first non-empty +`AGENTS.override.md` / `AGENTS.md`; Claude uses `CLAUDE_CONFIG_DIR` (otherwise +the user home `.claude`) and `CLAUDE.md`. The host resolves these locations; +UI supplies no arbitrary read/write destination. Absolute native paths in snapshots +are display and optimistic-write guards, not portable identifiers. A save checks +the snapshot's path and revision, replaces the file atomically, and reads the +effective source back. Failures retain the draft; reloading explicitly discards it +only after a successful read. Drafts stay separate by Agent. Updated instructions +load in a new native conversation; existing sessions are not rewritten or stopped. +Project instructions retain the upstream Agent's precedence rules. See +`fixtures/agent/agent-instructions-v1.json`. + +A user can also author one shared instruction text in platform-owned application +support storage and opt each supported Agent into a delimited block in its native +file. Synchronization preserves every byte outside that block. Turning a switch +off removes only that block. Enabled, unknown and out-of-date states come from +native snapshots, not a stored preference. Source saves and each target replacement +are atomic individually, not a cross-file transaction: per-Agent conflicts remain +visible and retryable while the saved shared draft and successful targets survive. +No startup/background synchronization or extra prompt injection occurs. A malformed +or duplicate marker is never repaired by overwriting it automatically. See +`fixtures/agent/shared-instructions-v1.json`. + Shared Monaco highlighting is owned by the current view/model pair. A queued callback for a closed or replaced model must finish without running against the new model or locking surviving documents. Primary, split and diff views share @@ -756,3 +781,12 @@ JDT-owned workspace build paths and the existing platform-owned JDT state. - 固定 codex-acp 的 `session_info_update._meta.codex.error.willRetry == true` 和非空 `turnId` 是 macOS 重试显示的证据;该通知可能没有 `title`。共享样例 `codexRetry` 由官方 ACP SDK 往返验证,Host 继续透传元数据,不新增请求/事件种类或网络重试策略。 - 状态隔离到会话,新进度清除重试显示,完成/失败/断连清理本轮状态,正在停止时不因迟到进度回退;不得显示未经脱敏的原始错误或从文本猜测重试次数。请求仍等待上游完成/取消确认,既有十分钟 prompt 上限和十秒取消上限不变。 - 这次交付 macOS 展示;Windows 对话 UI 的消费与真实验收仍待完成。展示状态只在内存中,不写入 bundle、安装目录或上游历史。 + + +Agent skill management groups files by Global/Project and retains their Agent +provenance. Codex-reported capabilities and Claude's partial directory inventory +are distinct; neither file presence nor a management-list row proves that the +current conversation has used a skill. Explicit create/import operations write +only to native user/project directories and preserve existing skills. Explicit toggles write native settings: Codex by discovered path via its official App Server API, Claude by name via skillOverrides in user or project-local settings. Claude states are configured visibility, not effective managed/session policy. Never persist a separate Lithe enabled flag, move packages to simulate disablement, or claim that a change revokes already loaded session content. File +contents remain untrusted document data. Settings discovery and file management +must not create conversations, send prompts, or interrupt existing Agent work. diff --git a/shared/contracts/mcp-registry-page.json b/shared/contracts/mcp-registry-page.json new file mode 100644 index 0000000000..3535d54d4a --- /dev/null +++ b/shared/contracts/mcp-registry-page.json @@ -0,0 +1,36 @@ +{ + "servers": [ + { + "server": { + "name": "io.example/documentation", + "title": "Documentation", + "description": "Example remote documentation service", + "version": "1.2.0", + "websiteUrl": "https://example.invalid/docs", + "remotes": [{"type": "streamable-http", "url": "https://example.invalid/mcp"}] + }, + "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}} + }, + { + "server": { + "name": "io.example/browser", + "description": "Example local package", + "version": "2.0.0", + "packages": [{"registryType": "npm", "identifier": "@example/browser", "version": "2.0.0", "transport": {"type": "stdio"}}] + } + }, + { + "server": { + "name": "io.example/database", + "version": "1.0.0", + "repository": {"url": "https://example.invalid/database"}, + "packages": [{"registryType": "npm", "identifier": "@example/database", "version": "1.0.0", "transport": {"type": "stdio"}, "environmentVariables": [{"name": "API_KEY", "isRequired": true, "isSecret": true}]}] + } + }, + { + "server": {"name": "io.example/removed"}, + "_meta": {"io.modelcontextprotocol.registry/official": {"status": "deleted"}} + } + ], + "metadata": {"nextCursor": "opaque/+ cursor?"} +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index b5807098c8..67f88f437d 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -603,6 +603,80 @@ stable error code and a user-facing message: ### MCP configuration +`mcp.probe` accepts a user-reviewed connection `{transport, command?, args?, env?, +url?, headers?}` with only those fields. Agent Host uses the pinned official +rmcp SDK for initialization, session handling and paginated `tools/list`; it +never sends `tools/call`. Results are `{tools:[{name,description}]}`, sorted by +name, deduplicated, bounded to 1000 tools/20 pages/2 MiB of metadata. stdio input +and each HTTP response body are bounded before decoding. A probe has a +30-second request budget plus bounded cleanup, cancellation is cooperative, +and stdio children and their process groups are reaped on completion or cancel. +HTTP accepts HTTPS and loopback HTTP, rejects credential-bearing URLs and +redirects, preserves TLS verification and explicit headers, and uses no cookie +store. It does not inherit another Agent's OAuth session or unmapped native +configuration options. Legacy `sse` remains a configurable transport but this +SDK probe returns `not_supported` rather than inventing a healthy status. +The host must require an explicit detection action; launching local code or +package runners also requires the product's execution confirmation. + +`mcp.toolPolicy` is a pure text operation with `{source,content,server,tools, +owned?,tool?,enabled?}`. `source` is `codex` or `claude`; omission of `tool` and +`enabled` reads current policy. It returns `{content,changed,owned,tools, +hasRestrictions}`. The returned tools contain `{name,enabled,editable}`. +Codex uses entry-local `disabled_tools`, preserving `enabled_tools`; Claude's +input is its user `settings.json`, editing only `permissions.deny` with exact +`mcp____` rules. Unknown settings/formatting +outside the targeted value survive. Existing unowned denies, broad wildcard +rules, allow-list exclusions and ambiguous normalized names are not relaxed. +Hosts retain the exact added tool names by native path/server, validate full +file snapshots before atomic writes, and never treat a read as ownership. +Policies describe the user configuration; project/managed settings and live +Agent sessions may differ. Claude server renames are refused while user MCP +restrictions exist, to avoid re-enabling tools under a new name. + +`mcp.commandLine` accepts `{action:"format",command,args}` to return `{line}`, +or `{action:"parse",line}` to return `{command,args}`. POSIX shell-style quoting +is only a lossless text projection: hosts pass argv directly, never evaluate a +shell, substitutions, pipes or redirects. Blank and embedded-newline arguments +are preserved; malformed quoting is rejected. These operations and probe +results use `shared/fixtures/mcp/tools-v1.json` as a synthetic contract fixture. + +`mcp.parseRegistry` accepts `{ content: string }` (at most 1 MiB UTF-8) and +normalizes an official MCP Registry v0.1 response into `{ entries, nextCursor }`. +Each entry has `id`, `title`, `summary`, `version`, nullable `publisher`, +`source: "registry"`, nullable HTTPS `website` and +nullable `draft: McpServer`. At most 100 input entries are accepted. Inactive +records are skipped, pagination cursors remain opaque, and links containing +credentials or unresolved variables are not offered. Only static HTTPS remote +endpoints without header/variable inputs, or versioned npm/PyPI stdio packages +without additional runtime/package/environment inputs, using the public default +registry and runner, produce drafts. npm versions must be complete releases; +mutable tags and major/minor ranges remain documentation-only. Other +records remain documentation-only. This operation never fetches, installs or +executes anything. Hosts own cancellable directory requests and require the +user to review and save a draft before modifying Agent configurations. +`shared/contracts/mcp-registry-page.json` is a portable synthetic response. + +`mcp.featuredRegistry` accepts `{}` and returns the same page shape with +`source: "featured"` from the Git-owned, compile-time catalog at +`rust/lithe-core/src/mcp/featured.json`. It performs no network, filesystem, +package resolution or execution. Catalog package versions are pinned; remote +requirements remain subject to the publisher's documentation. Hosts show this +page without contacting the Registry, extend matching entries on explicit +search, and retain available results when a directory request fails. +The release catalog includes Context7, Playwright, Chrome DevTools, Microsoft +Learn, GitHub, DeepWiki, Exa, Filesystem, Fetch and Memory. A featured entry may +add `setup: "bearer-token" | "allowed-directories"`: its draft is an incomplete +template, requiring an Authorization bearer value or at least one absolute +directory argument after the fixed Filesystem package argument. Hosts must +collect and validate these inputs before saving and must not select Agents by +default. Public Registry parsing never copies `setup`, even when input uses a +featured-looking ID. `shared/fixtures/mcp/featured-setup-v1.json` illustrates the +optional field; ordinary entries omit it. Brand marks are platform-owned, +read-only bundled assets, selected only for known featured IDs; public entries +keep the transport fallback. No response can select arbitrary resources or +cause remote image fetching. + `mcp.parseConfiguration` accepts `{ source: "codex" | "claude", scope: "global" | "project", content: string, projectPath?: string }` and returns `{ servers: McpServer[] }`. Claude project scope requires `projectPath` and reads @@ -628,12 +702,22 @@ lowercase SHA-256 hashes with separate purposes: `mcp.planWrite` accepts `{ source, scope, projectPath?, content?: string | null, operation: "set" | "remove", server?: McpServer, name?: string, -previousName?: string, expectedHash?: string, owned?: OwnedEntry[] }`. `set` +previousName?: string, expectedHash?: string, owned?: OwnedEntry[], +toolFilters?: {enabledTools?: string[], disabledTools?: string[]} }`. `set` requires `server`, `remove` requires `name`, and project scope requires `projectPath`. `previousName` identifies the original of a rename. Each owned entry is `{ name, definitionHash, sourceHash?: string }`; hosts must record the hashes returned from the actual write plan rather than hashes from the draft. +Successful Codex removals also return `toolFilters` when native `enabled_tools` +or `disabled_tools` exists. These lists contain tool names only; an empty +allow-list stays empty and malformed lists reject removal. Hosts retain them +by native path/server only after the complete write batch succeeds, separately +from live source ownership. A later `set` applies retained filters only when +inserting a new Codex entry; existing/renamed native policy always takes +precedence. Returned hashes cover the restored policy. Claude's user deny +rules remain in its separate settings file during server removal. + Automatic replacement/removal, including unchanged set plans used for repeated registration, requires an owned record whose name, `definitionHash` and `sourceHash` match the current entry. An existing raw entry omitted from discovery @@ -654,11 +738,11 @@ it is not owned. For rename, it refers to `previousName`, and cannot authorize an unrelated existing destination; destination ownership checks still apply. The response is `{ content, changed, action, conflict, definitionHash?, -sourceHash? }`. Actions are `inserted`, `updated`, `unchanged`, `removed`, `absent`, +sourceHash?, toolFilters? }`. Actions are `inserted`, `updated`, `unchanged`, `removed`, `absent`, or `conflict`; conflicts leave the input unchanged. Successful set plans return both hashes computed by parsing the actual output entry, including preserved unknown fields. Unchanged set plans also return the current entry's hashes; -removal/absence normally has no entry hashes. Hashes sent on the draft server +removal can return the old definition hash for filter retention; absence has no entry hashes. Hashes sent on the draft server are never trusted as write output or persisted into Agent configuration. ### Agent adapters @@ -683,6 +767,51 @@ and the 64 KiB limit still fail with redacted errors. Omitting this flag keeps the existing parser behavior; saving still requires all fields, a compatible protocol and explicit consent for HTTP. +`agent.instructions.read` takes `{agentId}` (`codex-acp` or `claude-acp`) and +returns `{agentId, path, content, exists, revision}`. It does not create files. +`agent.instructions.save` takes `{agentId, content, expectedPath, expectedRevision}`. +The host resolves the native user profile again and rejects a changed effective +path or byte revision before replacing the file. The result is a fresh effective +snapshot: clearing a Codex override may return the base `AGENTS.md` instead. +No prompt injection, account settings, repository files, or native history are +modified. Files must be regular UTF-8 files at most 1 MiB; terminal symlinks are +rejected. Read-only files cannot be overwritten. Existing file permissions are +preserved; Unix new files use `0600` and new directories use `0700`. +Save staging is a sibling temporary file removed on completion/failure. +Other Lithe saves are serialized by a non-waiting lock; external writers do not +share it, so revision checks are optimistic rather than an OS-wide transaction. +Errors use the existing envelope codes with stable `details` reasons including +`unsupported_agent`, `file_access`, `unsafe_file`, `file_changed`, `file_too_large`, +`invalid_utf8`, `profile_missing`, `profile_relative`, and `busy`. +The fixture is `shared/fixtures/agent/agent-instructions-v1.json`. + +`agent.instructions.shared` takes a platform-selected absolute `dataDirectory` +from the writable application-support adapter plus one tagged action: + +- `read`: returns `{file, agents}` without creating files. `file` is the shared + `Shared.md` snapshot; each Agent reports `{agentId, file, enabled, needsSync, + ownLineCount, issue}`. A native read or marker failure sets `enabled: null` and + an actionable issue rather than claiming disabled. +- `save`: takes `content`, `expectedRevision` for Shared.md and `files` guards + (`{agentId,path,revision}`) for targets. It saves the shared source, then updates + already-enabled native targets. A failed target retains its bytes and an issue; + other targets continue. `needsSync` compares the actual block with the saved source. +- `setEnabled`: takes `agentId`, `enabled`, the shared `expectedRevision` and one + `file` guard. It inserts/replaces or removes only the Lithe-delimited block in the + effective native file; it never disables the Agent or removes its own instructions. + +Marker tokens are `` and +``. The owned range includes its separator +newlines, so disabling restores the original bytes. Duplicate, incomplete or edited +markers are rejected; shared content may not contain the marker tokens. Native +source selection, size, permission, symlink and optimistic revision guards remain +those of `agent.instructions.save`. A nonblocking OS file lock in `.sync.lock` +serializes shared writes across app processes. Shared.md uses the same 1 MiB bound, +private Unix modes and atomic replacement; source/target commits are individually +atomic and partial success is explicit. Cancellation stops remaining targets and the +next page read reconciles actual state. None of these files is a build cache or +installed resource. The fixture is `fixtures/agent/shared-instructions-v1.json`. + `agent.status`, `agent.install`, `agent.uninstall`, and `agent.installCli` manage ACP adapters in `/agents/`, using the Node.js and npm the user installed. Lithe never installs Node.js or npm; the agents' own command-line tools are installed only through `agent.installCli` on an explicit user action. `agent.status` @@ -3485,3 +3614,55 @@ Core resolves its current stash position under the existing writer lease and rej an identity that is no longer present. Legacy positional references remain accepted. See `shared/fixtures/git/saved-stash-v1.json`. Native Shelf persistence is platform-owned; raw patch bytes are kept separately for index and working-tree versions. + + +### Agent skill management + +`agent.skills` accepts explicit `action` values `list`, `directories`, `read`, +`create`, `import`, and `setEnabled`, with `agentId` (`codex-acp` or `claude-acp`) and optional +absolute `workspace`. These management operations never create an Agent session, +send a prompt or stop a running conversation. Only an explicit `setEnabled` action changes native visibility settings. +The shared fixture is `shared/fixtures/agent/skills-management-v1.json`. + +`list` returns `skills`, `source`, `issues`, and native `directories` (`user`, +plus `repo` when a workspace is present). Each row has `name`, `description`, +absolute `path`, native `scope` (`user`, `repo`, `system`, `admin`), optional +`enabled` and optional `pluginId`. Paths are transient platform file-operation +metadata, not portable persisted identifiers. Rows are sorted by lowercased name +and path. Native Codex discovery uses the installed CLI's official `skills/list` +with the selected cwd and `forceReload: true`, reusing the bounded App Server +probe and process-tree cleanup. Unknown enablement remains null. Missing or +malformed native results fail explicitly; refresh failure retains previous rows +only within the same workspace and Agent. + +Claude `source: directory` is an explicitly partial inventory of +`CLAUDE_CONFIG_DIR/skills` (default `~/.claude/skills`) and the selected workspace's +`.claude/skills`. It does not emulate inherited-directory, plugin, policy or +session discovery. Its `enabled` reports configured visibility: user rows read user settings; repo rows merge user, project shared and project-local settings. Absent overrides mean on, off means false, name-only/user-invocable-only mean true. Invalid or unreadable configuration leaves the affected state null. This is not effective managed policy or evidence of session availability. Codex `source: agent` is native discovery. Platform UI +uses Global/Project as the main categories and Agent provenance as a filter. + +`setEnabled` requires an absolute discovered `source` path and boolean `enabled`; Claude also requires matching `scope`. It returns a fresh listing using the same shape as `list`. Codex validates the path with `skills/list`, calls official `skills/config/write`, then forces native readback and verifies the desired state. Its setting is global by path even for a project skill. Claude merges only the named `skillOverrides` on/off value into user `settings.json` for global skills, or project `.claude/settings.local.json` for project skills; it preserves unrelated JSON values and permissions, refuses malformed, linked or over-1-MiB files, uses a nonblocking cross-process lock and same-directory atomic replacement with a pre-publish snapshot check. Claude settings are by name, so same-named skills share a setting; project/managed settings can override user settings. Plugin skills are not managed through this operation. Existing session contents cannot be withdrawn: clients advise reconnecting, and never automatically interrupt a running conversation. No skill file is renamed or edited by toggling. + +`directories` returns only the native destination map and needs no installed +CLI. `read` takes the selected absolute `source` path to a `SKILL.md`, reads at +most 128 KiB of regular UTF-8 text and returns `content`. It never interprets or +executes instructions, scripts or dynamic content in the document. + +`create` requires `scope` (`user` or `repo`), portable `name` (1–64 lowercase +letters/digits separated by single hyphens), `description`, and `instructions`. +`import` requires `scope` and an absolute, user-selected `source` (`SKILL.md` or +its package directory). Writes return the resulting `path`. Codex targets +`~/.agents/skills` or `/.agents/skills`; Claude targets its native +configuration directory or `/.claude/skills`. Project writes require +an opened workspace. Imports preserve package contents and file permissions, +reject special files and symbolic links, and enforce 2,000 entries, 20 nested +levels, 128 KiB per SKILL.md, 8 MiB per resource file and 32 MiB total. Hidden staging is removed on failure or +cancellation; publication atomically refuses an existing target, including an +empty directory. Existing skills and import sources are never overwritten. +These are explicit user-owned documents, not downloaded/build/cache resources; +no app bundle, installation resource or shared worktree cache is writable. + +The macOS settings surface offers search, refresh, file details, native open and +reveal, creation and import. It keeps native loading separate from management +and rejects stale results after project changes. Windows can use the same Core +operations; the settings UI and Windows runtime validation remain pending. diff --git a/shared/fixtures/agent/agent-instructions-v1.json b/shared/fixtures/agent/agent-instructions-v1.json new file mode 100644 index 0000000000..9a91a9049f --- /dev/null +++ b/shared/fixtures/agent/agent-instructions-v1.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "readRequest": { + "command": "agent.instructions.read", + "payload": { + "agentId": "codex-acp" + } + }, + "file": { + "agentId": "codex-acp", + "path": "/fixture/user/.codex/AGENTS.md", + "content": "# Working agreements\nUse concise replies.\n", + "exists": true, + "revision": "5b94dd8e0a4a42bab37c0b1b7bb3d36e15d6cda57bb6cd32de40f2060e051228" + }, + "saveRequest": { + "command": "agent.instructions.save", + "payload": { + "agentId": "codex-acp", + "content": "# Updated\n", + "expectedPath": "/fixture/user/.codex/AGENTS.md", + "expectedRevision": "5b94dd8e0a4a42bab37c0b1b7bb3d36e15d6cda57bb6cd32de40f2060e051228" + } + }, + "windowsFile": { + "agentId": "claude-acp", + "path": "C:\\Users\\fixture\\.claude\\CLAUDE.md", + "content": "Claude only", + "exists": true, + "revision": "6200025904539440f7dde4fa1a3a0ca731c61c31bc8f0d188acf741caf0bdd40" + }, + "conflict": { + "ok": false, + "error": { + "code": "invalid_request", + "message": "Global instructions changed outside this editor. Your draft is kept; reload before saving.", + "details": "file_changed" + } + } +} diff --git a/shared/fixtures/agent/shared-instructions-v1.json b/shared/fixtures/agent/shared-instructions-v1.json new file mode 100644 index 0000000000..607a41288e --- /dev/null +++ b/shared/fixtures/agent/shared-instructions-v1.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "readRequest": { + "command": "agent.instructions.shared", + "payload": { + "dataDirectory": "/fixture/app-support/AgentInstructions", + "action": "read" + } + }, + "library": { + "file": { + "agentId": "shared", + "path": "/fixture/app-support/AgentInstructions/Shared.md", + "content": "Use concise Chinese replies.", + "exists": true, + "revision": "source-sha256" + }, + "agents": [ + { + "agentId": "codex-acp", + "file": { + "agentId": "codex-acp", + "path": "/fixture/user/.codex/AGENTS.md", + "content": "Own rules\n\nUse concise Chinese replies.\n\n", + "exists": true, + "revision": "native-sha256" + }, + "enabled": true, + "needsSync": false, + "ownLineCount": 1, + "issue": null + }, + { + "agentId": "claude-acp", + "file": null, + "enabled": null, + "needsSync": false, + "ownLineCount": 0, + "issue": "The instruction path must be a regular file, not a symbolic link or directory." + } + ] + } +} diff --git a/shared/fixtures/agent/skills-management-v1.json b/shared/fixtures/agent/skills-management-v1.json new file mode 100644 index 0000000000..9e39f281e1 --- /dev/null +++ b/shared/fixtures/agent/skills-management-v1.json @@ -0,0 +1,144 @@ +{ + "requests": { + "list": { + "command": "agent.skills", + "payload": { + "action": "list", + "agentId": "codex-acp", + "workspace": "/fixture/workspace" + } + }, + "directories": { + "command": "agent.skills", + "payload": { + "action": "directories", + "agentId": "claude-acp", + "workspace": "/fixture/workspace" + } + }, + "create": { + "command": "agent.skills", + "payload": { + "action": "create", + "agentId": "codex-acp", + "workspace": "/fixture/workspace", + "scope": "repo", + "name": "example-skill", + "description": "Use for a specific workflow", + "instructions": "Inspect the project and explain the result." + } + }, + "import": { + "command": "agent.skills", + "payload": { + "action": "import", + "agentId": "claude-acp", + "workspace": "/fixture/workspace", + "scope": "user", + "source": "/fixture/import/example-skill" + } + }, + "read": { + "command": "agent.skills", + "payload": { + "action": "read", + "agentId": "codex-acp", + "source": "/fixture/home/.agents/skills/shared-checks/SKILL.md" + } + }, + "setEnabled": { + "command": "agent.skills", + "payload": { + "action": "setEnabled", + "agentId": "codex-acp", + "workspace": "/fixture/workspace", + "source": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "enabled": false, + "scope": "repo" + } + } + }, + "nativeCodex": { + "data": [ + { + "cwd": "/fixture/workspace", + "skills": [ + { + "name": "plugin-helper", + "description": "Use the plugin helper", + "path": "/fixture/plugins/helper/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": "helper@example" + }, + { + "name": "project-workflow", + "description": "Use for this project", + "path": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + }, + { + "name": "shared-checks", + "description": "Use for common checks", + "path": "/fixture/home/.agents/skills/shared-checks/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": null + } + ], + "errors": [] + } + ] + }, + "normalizedCodex": { + "skills": [ + { + "name": "plugin-helper", + "description": "Use the plugin helper", + "path": "/fixture/plugins/helper/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": "helper@example" + }, + { + "name": "project-workflow", + "description": "Use for this project", + "path": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + }, + { + "name": "shared-checks", + "description": "Use for common checks", + "path": "/fixture/home/.agents/skills/shared-checks/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": null + } + ], + "source": "agent", + "issues": [], + "directories": {} + }, + "claudeInventory": { + "skills": [ + { + "name": "local-review", + "description": "Review this project's changes", + "path": "/fixture/workspace/.claude/skills/local-review/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + } + ], + "source": "directory", + "issues": [], + "directories": { + "user": "/fixture/home/.claude/skills", + "repo": "/fixture/workspace/.claude/skills" + } + } +} diff --git a/shared/fixtures/mcp/featured-setup-v1.json b/shared/fixtures/mcp/featured-setup-v1.json new file mode 100644 index 0000000000..a2b2df565a --- /dev/null +++ b/shared/fixtures/mcp/featured-setup-v1.json @@ -0,0 +1,48 @@ +{ + "entries": [ + { + "id": "lithe.featured/example-token", + "title": "Example token service", + "summary": "Synthetic setup contract", + "publisher": "Example", + "source": "featured", + "version": "", + "website": "https://example.invalid/docs", + "setup": "bearer-token", + "draft": { + "name": "example-token", + "transport": "http", + "command": null, + "args": [], + "env": [], + "url": "https://example.invalid/mcp", + "headers": [], + "definitionHash": "" + } + }, + { + "id": "lithe.featured/example-folders", + "title": "Example allowed folders", + "summary": "Synthetic setup contract", + "publisher": "Example", + "source": "featured", + "version": "1.2.3", + "website": null, + "setup": "allowed-directories", + "draft": { + "name": "example-folders", + "transport": "stdio", + "command": "npx", + "args": [ + "-y", + "@example/filesystem@1.2.3" + ], + "env": [], + "url": null, + "headers": [], + "definitionHash": "" + } + } + ], + "nextCursor": null +} diff --git a/shared/fixtures/mcp/tools-v1.json b/shared/fixtures/mcp/tools-v1.json new file mode 100644 index 0000000000..5384e9f4e1 --- /dev/null +++ b/shared/fixtures/mcp/tools-v1.json @@ -0,0 +1,52 @@ +{ + "probeRequest": { + "transport": "http", + "url": "https://example.invalid/mcp", + "command": null, + "args": [], + "env": [], + "headers": [] + }, + "probeResult": { + "tools": [ + { + "name": "read", + "description": "Synthetic read-only tool metadata" + } + ] + }, + "policyRequest": { + "source": "claude", + "content": "{}", + "server": "docs", + "tools": [ + "read" + ], + "owned": [], + "tool": "read", + "enabled": false + }, + "policyResult": { + "content": "{\n \"permissions\": {\n \"deny\": [\"mcp__docs__read\"]\n}\n}", + "changed": true, + "owned": [ + "read" + ], + "tools": [ + { + "name": "read", + "enabled": false, + "editable": true + } + ], + "hasRestrictions": true + }, + "commandLine": { + "command": "/fixture/my tools/server", + "args": [ + "", + "folder with spaces", + "a'b" + ] + } +} diff --git a/shared/platform-feature-matrix/features/agent-global-instructions.json b/shared/platform-feature-matrix/features/agent-global-instructions.json new file mode 100644 index 0000000000..61bbe2f0e0 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-global-instructions.json @@ -0,0 +1,40 @@ +{ + "id": "agent-global-instructions", + "area": "AI", + "group": "Agent 对话", + "capability": "全局指令卡片:一份共享正文按 Agent 显式启停同步到原生用户文件的 Lithe 区块,保留自有内容、实际路径、独立草稿及逐项错误;支持展开编辑、外部修改校验、原子保存和显式重新加载;取消在 Core 登记边界同步交付,已取消保存不因后台任务启动竞态写入", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift", + "macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift", + "macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift", + "macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift", + "macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift", + "rust/lithe-agent-host/src/instructions.rs", + "macos/Sources/Lithe/Views/App/SettingsView.swift", + "macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift", + "macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift", + "rust/lithe-agent-host/src/instructions/shared.rs", + "rust/lithe-agent-host/src/instructions/shared/tests.rs", + "macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift", + "macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/instructions.rs", + "rust/lithe-core/src/agent/instructions.rs", + "windows/tauri/src-tauri/src/platform.rs", + "shared/fixtures/agent/agent-instructions-v1.json", + "rust/lithe-agent-host/src/instructions/shared.rs", + "shared/fixtures/agent/shared-instructions-v1.json" + ] + }, + "owner": "Agent", + "verification": "在同一份 macOS 预览的 AI 分组中同时检查「指令」与「技能」入口,来回切换确认两个功能都可加载。macOS:打开设置 AI > 指令,分别查看 Codex、Claude 的实际用户路径,编辑保存后新建对话验证原生加载;确认 CODEX_HOME/CLAUDE_CONFIG_DIR、自定义目录、Codex 非空 override 优先和清空 override 后返回基础文件;切换 Agent 和设置页保留未保存草稿,文件被外部修改时拒绝覆盖并保留草稿,显式重新加载需确认且读取失败不丢草稿;回归中文/英文、深浅主题和窄宽窗口的原生编辑器、选择器、搜索及保存状态。运行 AgentInstructionsFeatureModelTests、AgentInstructionsAppearanceTests、AgentInstructionsContractTests、SettingsSidebarLayoutTests、SettingsSearchVocabularyTests,以及 Host/Core 计时测试;Windows 共享文件命令可经通用 platform_invoke 分派,设置页与 Windows 原生运行验收待完成。 新增共享卡片、Agent 图标/路径/自有行数与展开编辑;隔离用户目录真实保存、启用两 Agent、更新、关闭其中一个,逐字节确认私有内容不变,重新打开从原生区块回读开关;外部修改、损坏标记、只读目标与取消后不覆盖,单项失败继续其他项并保留错误。SharedInstructionsFeatureTests 覆盖连续开关排队、取消、失败和草稿;深浅色中英文宽窄布局需实机检查。 注册前取消回归:确定性控制调用前取消、请求编码时取消,真实 Core 返回 cancelled,已有共享源不变、缺失源和锁不创建,后续有效操作仍成功。CI test-stability-macos 产物包含 instructions-ui 的中英文、深浅主题、宽窄原生截图。", + "notes": "共享源保存在平台 storage adapter 的 Application Support/Lithe/AgentInstructions/Shared.md,不额外拼接提示词。开关只控制 Lithe 区块,新会话由上游加载;按文件原子替换,跨文件部分失败可见。源、锁、暂存文件及原生指令均为用户状态,不可工作树复用,不写发行资源。" +} diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json new file mode 100644 index 0000000000..63d9f42587 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -0,0 +1,37 @@ +{ + "id": "agent-skill-management", + "area": "AI", + "group": "Agent 对话", + "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈、保存提示不推动列表、仅待确认行禁用、其他行保持颜色并按序保存);操作菜单保留所属窗口焦点和开启开关的颜色;技能目录最终发布在 macOS/Linux/Windows 均原子拒绝已有目标(包含空目录)", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift", + "macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift", + "rust/lithe-agent-host/src/skills.rs", + "rust/lithe-agent-host/src/skills/files.rs", + "macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift", + "rust/lithe-agent-host/src/skills/tests.rs", + "shared/fixtures/agent/skills-management-v1.json", + "rust/lithe-agent-host/src/skills/settings.rs", + "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift", + "macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift", + "macos/Tests/LitheTests/AgentSkillsCancellationTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/agent/mod.rs", + "rust/lithe-agent-host/src/skills.rs", + "shared/fixtures/agent/skills-management-v1.json", + "rust/lithe-agent-host/src/skills/files.rs", + "rust/lithe-agent-host/src/skills/tests.rs" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、待确认行禁止重复提交,其他行保持原生开启颜色并可独立点击排队;控制同一 Agent 的两次原生写入按序确认,不丢失已保存的状态,单行失败回滚且后续队列继续,失败提示不被其他成功覆盖;切项目或退出时未开始的写入不再执行、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。 Windows 原生 CI 回归 publication_cannot_replace_an_existing_empty_directory,覆盖暂存后目标出现、普通文件与非空目录字节保留、Unicode/空格目标完整发布,以及 NUL 路径拒绝。Windows 使用 MoveFileExW 且不启用覆盖或跨卷复制,不以 std::fs::rename 的平台行为推断排他性;文件系统用例通过不代表设置页或会话已验收。 AgentSkillsCancellationTests 通过真实桥接验证创建、导入和启停在服务入口前取消时不改变技能包或原生配置;编码时取消经 requestStarted 注册握手补传,文件分派前停止且后续请求可成功。" +} diff --git a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json index 0267caa645..73233b9229 100644 --- a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json +++ b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json @@ -2,7 +2,7 @@ "id": "macos-shared-product-dropdowns", "area": "界面", "group": "全局共享控件", - "capability": "macOS 全部产品操作菜单、选值下拉框与自定义弹窗使用 Git Log 筛选、设置和 Project 共同使用的共享外框及行尺寸,直接打开且无系统弹跳动画;顶部项目/分支按钮打开期间保持原hover底色;Monaco 编辑区现有右键菜单同样使用共享呈现器及 IDEA 官方原色图标;顶部项目菜单按内容宽度布局,分支菜单共享 375pt 基准及全局搜索输入框,菜单锚点位于完整工具栏槽位下缘,内容图标使用官方 16pt 明暗 SVG;配置侧边子菜单支持原文名称与简短说明,按实际宽度测量多行行高,键盘滚动后的选择保持到真实指针移动;原生浮层更新与尺寸反馈合并到后续主队列执行,关闭或锚点卸载取消待执行请求", + "capability": "macOS 全部产品操作菜单、选值下拉框与自定义弹窗使用 Git Log 筛选、设置和 Project 共同使用的共享外框及行尺寸,直接打开且无系统弹跳动画;顶部项目/分支按钮打开期间保持原hover底色;Monaco 编辑区现有右键菜单同样使用共享呈现器及 IDEA 官方原色图标;顶部项目菜单按内容宽度布局,分支菜单共享 375pt 基准及全局搜索输入框,菜单锚点位于完整工具栏槽位下缘,内容图标使用官方 16pt 明暗 SVG;配置侧边子菜单支持原文名称与简短说明,按实际宽度测量多行行高,键盘滚动后的选择保持到真实指针移动;原生浮层更新与尺寸反馈合并到后续主队列执行,关闭或锚点卸载取消待执行请求;操作菜单保留所属窗口焦点和开启开关的颜色", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -34,7 +34,8 @@ "macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift", "macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift", "macos/Tests/LitheTests/AgentSessionSelectorInteractionTests.swift", - "macos/Tests/LitheTests/AgentActivityPresentationTests.swift" + "macos/Tests/LitheTests/AgentActivityPresentationTests.swift", + "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift" ] }, "windows": { @@ -45,6 +46,6 @@ ] }, "owner": "macOS UI", - "verification": "按 PR 迁移清单逐项打开 Agent、App/设置、Database、Debug、Editor、Git、GitHub、Java、Search、Diff、Workbench 的菜单与选择面板,检查同一共享外框、圆角、边框、单行行高、字体和无弹跳展开;选中项、禁用条件、动作、动态列表、危险操作确认及键盘操作保持原语义。顶部项目/分支按钮打开期间保持原hover底色,移入菜单后仍保留,关闭后释放;不使用蓝色菜单选中色。原生子菜单保留父面板并只接收自己的按键,Esc/外部点击按层关闭,所有方向保持屏幕边界,锚点卸载/移出窗口清理面板与监视器。真实 NSHostingView 回归验证环境对象/语言完整继承。保留系统应用菜单、系统对话框、编辑器补全/悬停文档与显式 segmented 选项;静态门禁禁止产品恢复 SwiftUI Menu、popover、NSPopUpButton 或非 segmented 的原生 Picker。完整人工视觉验收保持 pending,Windows 本次不改。 Monaco 编辑区保留既有条目与命令,剪切/复制/粘贴/格式化使用 IDEA 官方 16pt 明暗 SVG,无图标的 action 留空并对齐;确认主编辑区、分屏和 diff 的右键菜单取消/关闭、焦点与现有动作正常;执行 scripts/probe-macos-monaco.sh --workbench-tests 验证正式前端的菜单桥接和命令事件。 右键编辑器标签的 Copy Path / Reference 子菜单从触发行旁展开;父菜单有分隔线或滚动时仍对齐可见行。打开/关闭/切换子菜单固定主菜单,屏幕边缘允许子菜单上移或向左展开;Copy Relative Path 完整显示,带快捷键、勾选和图标的宽度计算保持一致。 顶部项目菜单确认随名称/路径宽度自然变化、长路径受共享边界限制,项目徽标20pt;两个入口按钮与菜单留出工具栏槽位内的4/5pt间距。分支输入框复用 Git Log 全局样式,输入法未提交时隐藏占位并在hover显示I-beam,官方16pt图标和共享蓝色hover正常。 项目徽标字母使用共享ProjectAvatarBadge,20pt标识对应13pt JetBrains Mono SemiBold;核对顶部、项目菜单和欢迎页,颜色与大小保留。 共享 LitheIcons 资源入口兼容省略 .svg 的调用,明暗兄弟路径使用正确后缀;实际打包资源检查直接路径/显式后缀解析一致,不再因后缀缺失显示空图标或错误回退。 Project/Dependencies、Terminal shell 和动作菜单均按触发控件边界定位,鼠标点击位置和键盘触发不改变锚点。 Agent 主画布与编辑器共用 editor 背景,标题、上下文区和边框读取共享主题 token;明暗及应用配色切换后不得残留局部固定灰色。 从原生菜单执行剪切、复制、粘贴时恢复 WebKit 焦点并使用原生编辑命令,避免异步菜单回调丢失 DOM 用户手势;桥接返回 handled 防止 Monaco 重复执行,其他命令仍由 Monaco 运行。实际 WKWebView 剪贴板与前端 workbench 集成检查覆盖该路径。 另检查分组折叠/恢复、搜索焦点与 Git Log 共享外框、子菜单横向定位及两层 Esc 关闭。 深浅主题下分别验证动作菜单与自定义弹窗:再次左键点击原按钮关闭,点击另一按钮直接切换,点击空白处关闭;打开嵌套菜单后仍保留父窗口。 深浅主题下检查 Agent 速度/思考独立子菜单:主面板固定、子窗口贴齐触发行并在右缘向左翻转,说明换行不越出行高、长列表可键盘滚动至末项;滚动完成并捕获渲染后按 Return 仍选择键盘目标,静止鼠标的合成悬停不能抢走选择;实际移动鼠标后恢复指针选择。Esc 按层关闭,选值和锚点拆除释放所有子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板及搜索保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖模型搜索输入法组合后提交,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 原生窗口回归另检查连续内容/尺寸变化只交付最新值,尺寸回调不会同步重入,深浅外观下更新合并为一个面板;关闭或卸载发生在首次打开或更新交付之前时,不得重新打开旧面板。", + "verification": "按 PR 迁移清单逐项打开 Agent、App/设置、Database、Debug、Editor、Git、GitHub、Java、Search、Diff、Workbench 的菜单与选择面板,检查同一共享外框、圆角、边框、单行行高、字体和无弹跳展开;选中项、禁用条件、动作、动态列表、危险操作确认及键盘操作保持原语义。顶部项目/分支按钮打开期间保持原hover底色,移入菜单后仍保留,关闭后释放;不使用蓝色菜单选中色。原生子菜单保留父面板并只接收自己的按键,Esc/外部点击按层关闭,所有方向保持屏幕边界,锚点卸载/移出窗口清理面板与监视器。真实 NSHostingView 回归验证环境对象/语言完整继承。保留系统应用菜单、系统对话框、编辑器补全/悬停文档与显式 segmented 选项;静态门禁禁止产品恢复 SwiftUI Menu、popover、NSPopUpButton 或非 segmented 的原生 Picker。完整人工视觉验收保持 pending,Windows 本次不改。 Monaco 编辑区保留既有条目与命令,剪切/复制/粘贴/格式化使用 IDEA 官方 16pt 明暗 SVG,无图标的 action 留空并对齐;确认主编辑区、分屏和 diff 的右键菜单取消/关闭、焦点与现有动作正常;执行 scripts/probe-macos-monaco.sh --workbench-tests 验证正式前端的菜单桥接和命令事件。 右键编辑器标签的 Copy Path / Reference 子菜单从触发行旁展开;父菜单有分隔线或滚动时仍对齐可见行。打开/关闭/切换子菜单固定主菜单,屏幕边缘允许子菜单上移或向左展开;Copy Relative Path 完整显示,带快捷键、勾选和图标的宽度计算保持一致。 顶部项目菜单确认随名称/路径宽度自然变化、长路径受共享边界限制,项目徽标20pt;两个入口按钮与菜单留出工具栏槽位内的4/5pt间距。分支输入框复用 Git Log 全局样式,输入法未提交时隐藏占位并在hover显示I-beam,官方16pt图标和共享蓝色hover正常。 项目徽标字母使用共享ProjectAvatarBadge,20pt标识对应13pt JetBrains Mono SemiBold;核对顶部、项目菜单和欢迎页,颜色与大小保留。 共享 LitheIcons 资源入口兼容省略 .svg 的调用,明暗兄弟路径使用正确后缀;实际打包资源检查直接路径/显式后缀解析一致,不再因后缀缺失显示空图标或错误回退。 Project/Dependencies、Terminal shell 和动作菜单均按触发控件边界定位,鼠标点击位置和键盘触发不改变锚点。 Agent 主画布与编辑器共用 editor 背景,标题、上下文区和边框读取共享主题 token;明暗及应用配色切换后不得残留局部固定灰色。 从原生菜单执行剪切、复制、粘贴时恢复 WebKit 焦点并使用原生编辑命令,避免异步菜单回调丢失 DOM 用户手势;桥接返回 handled 防止 Monaco 重复执行,其他命令仍由 Monaco 运行。实际 WKWebView 剪贴板与前端 workbench 集成检查覆盖该路径。 另检查分组折叠/恢复、搜索焦点与 Git Log 共享外框、子菜单横向定位及两层 Esc 关闭。 深浅主题下分别验证动作菜单与自定义弹窗:再次左键点击原按钮关闭,点击另一按钮直接切换,点击空白处关闭;打开嵌套菜单后仍保留父窗口。 深浅主题下检查 Agent 速度/思考独立子菜单:主面板固定、子窗口贴齐触发行并在右缘向左翻转,说明换行不越出行高、长列表可键盘滚动至末项;滚动完成并捕获渲染后按 Return 仍选择键盘目标,静止鼠标的合成悬停不能抢走选择;实际移动鼠标后恢复指针选择。Esc 按层关闭,选值和锚点拆除释放所有子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板及搜索保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖模型搜索输入法组合后提交,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 原生窗口回归另检查连续内容/尺寸变化只交付最新值,尺寸回调不会同步重入,深浅外观下更新合并为一个面板;关闭或卸载发生在首次打开或更新交付之前时,不得重新打开旧面板。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。", "notes": "顶部分支菜单的 Recent/Local/Remote/Tags 支持点击折叠,Recent 按命名空间分组;搜索覆盖全部引用,清空后保留各组折叠状态。分支动作在右侧按触发行对齐,屏幕边缘自动向左展开。 搜索框共用 Git Log 的控件尺寸、占位、IME 和焦点外框,按上游 SearchFieldWithExtension 显式使用弹窗背景;弹出时焦点留在面板,直接输入或 ⌘F 才进入编辑器。 共享弹窗显式观察应用 colorScheme,内容与外框继承同一环境,原生面板同步明暗外观;应用外观与系统外观不同时不再出现黑色搜索框和浅色文字。 见 #1138:已确认原生布局递归栈溢出,具体触发控件未确定;当前验证覆盖同步反馈风险的防护,不代表原始崩溃已经完整复现。" } diff --git a/shared/platform-feature-matrix/features/mcp-registry-discovery.json b/shared/platform-feature-matrix/features/mcp-registry-discovery.json new file mode 100644 index 0000000000..187e72a735 --- /dev/null +++ b/shared/platform-feature-matrix/features/mcp-registry-discovery.json @@ -0,0 +1,34 @@ +{ + "id": "mcp-registry-discovery", + "area": "AI", + "group": "IDE MCP", + "capability": "同页上方管理服务器资源库,下方默认显示离线可用的内置精选;搜索时查询官方 MCP Registry、分页并与精选去重,失败保留可用匹配并提供显式直连重试;静态远程或简单固定版本 npm/PyPI 入口进入平铺编辑弹窗,选择 Agent 并确认后保存;十个常用精选随应用打包品牌图标和运行方式标签,GitHub 令牌及 Filesystem 允许目录须补全后保存", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/mcp/registry.rs", + "shared/contracts/mcp-registry-page.json", + "macos/Sources/Lithe/Core/Ports/McpRegistry.swift", + "macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpRegistry.swift", + "macos/Sources/Lithe/Application/Features/McpRegistryFeatureModel.swift", + "macos/Sources/Lithe/Views/App/McpDiscoveryView.swift", + "macos/Tests/LitheTests/McpRegistryTests.swift", + "rust/lithe-core/src/mcp/featured.json", + "macos/Tests/LitheTests/McpRegistryIntegrationTests.swift", + "macos/Sources/Lithe/Theme/McpBrandIcon.swift", + "macos/Sources/Lithe/Resources/McpIcons/NOTICE.json", + "macos/Tests/LitheTests/McpArgumentRoundTripTests.swift", + "shared/fixtures/mcp/featured-setup-v1.json" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/rust-core-api.md" + ] + }, + "owner": "Agent", + "verification": "Core 验证精选目录、删除记录过滤、固定包版本、复杂输入不猜配置、链接和大小上限;模型覆盖离线首页不请求网络、失败保留精选、显式直连和路由分页隔离、在线连接去重、取消和旧响应。实机打开页面立即显示精选,标题右侧来源标记为灰色普通文字“精选,以及官方 MCP Registry”,辅助功能树不包含该标记的链接或按钮;系统代理失败时仍显示匹配,点击直连重试后经生产 adapter 查询官方目录并加载更多,再切回系统代理确认模式生效;添加草稿取消不改 Agent 配置。公开目录请求继续校验证书,系统代理、Agent 网络和发行目录保持不变。联网集成用 LITHE_RUN_MCP_REGISTRY_INTEGRATION=1 显式开启,普通测试不依赖网络。Windows UI 尚未接入。 新增精选应检查十项本地图标完整且公开条目回退传输图标;深浅色宽窄卡片保持可读;GitHub 空令牌、Filesystem 缺少或相对目录不能保存,补全后经两 Agent 原生格式写入并回读,目录空格保持。未执行或联网调用服务器不计作实际服务验收。" +} diff --git a/shared/platform-feature-matrix/features/mcp-server-library.json b/shared/platform-feature-matrix/features/mcp-server-library.json index 72729865dc..80007d4201 100644 --- a/shared/platform-feature-matrix/features/mcp-server-library.json +++ b/shared/platform-feature-matrix/features/mcp-server-library.json @@ -2,7 +2,7 @@ "id": "mcp-server-library", "area": "AI", "group": "IDE MCP", - "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理", + "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理;支持资源库搜索、原生弹窗编辑和标准 mcpServers JSON 预览导入;紧凑图标配置弹窗及精确命令行参数投影,主动 stdio/Streamable HTTP 工具检测,按 Codex/Claude 原生规则独立管理工具开关并保留外部禁用项;关闭及删除后的 Codex 原生工具名单按路径/名称保留,再添加时恢复", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -20,7 +20,14 @@ "rust/lithe-core/src/mcp/tests.rs", "macos/Tests/LitheTests/McpArgumentRoundTripTests.swift", "macos/Tests/LitheTests/McpSymlinkConfigurationTests.swift", - "macos/Tests/LitheTests/McpWriteProtectionTests.swift" + "macos/Tests/LitheTests/McpWriteProtectionTests.swift", + "macos/Sources/Lithe/Views/App/McpDiscoveryView.swift", + "macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift", + "macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift", + "macos/Sources/Lithe/Views/App/McpToolsPanel.swift", + "rust/lithe-agent-host/src/mcp_probe.rs", + "rust/lithe-core/src/mcp/tools.rs", + "macos/Tests/LitheTests/McpToolsFeatureTests.swift" ] }, "windows": { @@ -31,6 +38,6 @@ ] }, "owner": "Agent", - "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。", - "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。" + "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。 Codex 移除成功后只保留允许/禁用工具名到无 sourceHash 的账本条目,重新插入时恢复;已有原生条目和重命名不被旧保留值覆盖,格式错误拒绝移除,空允许名单仍拒绝全部工具。保留名单不构成现存文件所有权,不保存认证信息。取消探测复用 Core 注册回调,无重试计时器。 Codex 成功重命名后移动同一配置路径下的精确工具名所有权并清除旧名记录,不接管外部规则或合并目标旧记录;批量失败不迁移。已检测行的 Agent 来源改变时释放旧快照并重读本地策略,探测中变化采用最新来源,不重复连接服务器。", + "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。 检测需显式触发,本地命令须确认;覆盖分页、工具数量/字节上限、实际 stdio/本地 HTTP、取消、虚拟超时和子进程清理。逐 Agent 关闭/开启自有规则并回读,外部 deny/allow-list 不放宽,过期快照不覆盖文件。全局页打开不启动服务器;验证工具列表与编辑器深浅色宽窄布局。SSE 配置保留但检测明确不支持,未验收的原生会话工具行为不能据配置测试宣称 verified。 McpArgumentRoundTripTests.disabledToolsSurviveServerRemovalAndReopening 对 Agent 开关、编辑器保存、删除重加三条真实隔离文件路径验证:工具仍禁用,外部 deny/allow-list 不放宽、另一 Agent 不变、新页面读回且仅 Lithe 自有规则可重新开启。Core 回归覆盖空 allow-list、非法类型、现存原生规则优先及恢复后完整哈希。CI test-stability-macos 产物包含 mcp-ui 的真实产品编辑器及工具面板深浅主题、宽窄截图。 验证禁用自有工具后重命名、重新读取并开启,外部 deny/allow-list 保持不可编辑,旧名重用不继承所有权;检测后及探测期间新增、移除 Agent 时刷新对应策略并释放旧快照,读取失败可见且不影响另一 Agent,服务器探测次数保持一次。" } diff --git a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json index 2626af5b02..f25185c1fc 100644 --- a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json +++ b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json @@ -43,6 +43,6 @@ "verificationStatus": "pending" }, "owner": "Run", - "verification": "运行一个长驻服务(如 Spring Boot)、一个 setsid 脱离的后台进程和一个忽略终止信号的程序,分别通过菜单退出、更新重启和对主进程 kill -9 关闭 IDE,确认除用户选择保留的项目外,带本实例 LITHE_PROCESS_SCOPE 的进程全部退出、端口已释放;主进程和监护进程同时被结束后再次启动,确认残留进程出现在启动清扫确认中。", + "verification": "运行一个长驻服务(如 Spring Boot)、一个 setsid 脱离的后台进程和一个忽略终止信号的程序,分别通过菜单退出、更新重启和对主进程 kill -9 关闭 IDE,确认除用户选择保留的项目外,带本实例 LITHE_PROCESS_SCOPE 的进程全部退出、端口已释放;主进程和监护进程同时被结束后再次启动,确认残留进程出现在启动清扫确认中。 Windows Run 原生测试在停止前持有后代进程句柄,以两秒退出信号确认根进程和后代结束,以零等待确认另一窗口仍在运行;首字节就绪等待有界,夹具在断言失败时也清理所属进程树。", "notes": "macOS 用 LITHE_PROCESS_SCOPE 标记本实例启动的进程,退出时做最终清扫;崩溃由随应用启动的监护进程兜底(Agent 任务和后台服务留到下次启动再确认)。分类规则在 Rust Core(processLifecycle.inventory),Windows 可直接复用。两端在关闭项目或窗口、退出、安装更新前按 Rust Core 的 runServicesOnExit 规则列出运行服务并询问(可设为直接停止)。Windows 的运行会话、调试适配器和语言服务器放在 Job Object 中,停止和 IDE 崩溃时都按整组结束;菜单退出走窗口关闭守卫,更新安装前先停止运行服务。Windows 终端和 Agent 进程树尚未放入 Job Object,也没有监护进程和启动清扫,IDE 崩溃时它们仍可能残留。" } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 3edebf72e8..a8ef65b2dc 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -369,6 +369,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -2855,21 +2861,28 @@ dependencies = [ "agent-client-protocol", "bytes", "flate2", + "fs2", "futures", + "http", "http-body-util", "hyper", "hyper-util", "kill_tree", "libc", "reqwest 0.12.28", + "rmcp", "semver", "serde", "serde_json", + "serde_yaml_ng", + "sha2", + "sse-stream", "tokio", "tokio-util", "tungstenite", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -2893,6 +2906,7 @@ dependencies = [ "serde_yaml_ng", "sha1", "sha2", + "shell-words", "toml 1.1.4+spec-1.1.0", "toml_edit 0.25.13+spec-1.1.0", "tree-sitter", @@ -4493,6 +4507,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +dependencies = [ + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "indexmap 2.14.0", + "pin-project-lite", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + [[package]] name = "rsa" version = "0.9.10" @@ -5230,6 +5267,19 @@ dependencies = [ "der", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6148,6 +6198,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index ca6a2dfe8e..7c7b1fe8d4 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -3723,15 +3723,81 @@ mod tests { assert_eq!(left, run_session_key("project-a", "primary")); } + #[cfg(windows)] + struct OwnedRunTree { + key: RunSessionKey, + root: Child, + descendant: Option, + output: Option, + } + + #[cfg(windows)] + impl OwnedRunTree { + fn assert_ended(&mut self) { + assert!(test_process_ended(&self.root, 2_000).expect("root exit signal")); + // The native wait has already observed exit, so reaping cannot block. + self.root.wait().expect("reap root"); + assert!(test_process_ended(self.descendant.as_ref().unwrap(), 2_000) + .expect("descendant exit signal")); + } + + fn assert_running(&mut self) { + assert!(self.root.try_wait().unwrap().is_none()); + assert!(!test_process_ended(self.descendant.as_ref().unwrap(), 0) + .expect("descendant is still running")); + } + } + + #[cfg(windows)] + impl Drop for OwnedRunTree { + fn drop(&mut self) { + // Assertion/setup failures must also reclaim only this fixture's tree. + if let Err(error) = stop_sessions( + take_owned_sessions(|key| key == &self.key), + Duration::from_secs(1), + ) { + eprintln!("fixture cleanup: {error}"); + // A failed stop may reinsert the session. Close its job handle + // as the final crash-cleanup fallback instead of retaining it. + drop(take_owned_sessions(|key| key == &self.key)); + } + let _ = self.root.kill(); + if test_process_ended(&self.root, 1_000).unwrap_or(false) { + let _ = self.root.wait(); + } else { + eprintln!("fixture root did not exit during cleanup"); + } + if let Some(descendant) = &self.descendant { + if !test_process_ended(descendant, 1_000).unwrap_or(false) { + eprintln!("fixture descendant did not exit during cleanup"); + } + } + } + } + + #[cfg(windows)] + fn test_process_ended( + process: &impl std::os::windows::io::AsRawHandle, + timeout_ms: u32, + ) -> std::io::Result { + use windows_sys::Win32::Foundation::{WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + // SAFETY: the borrowed process handle stays open throughout this bounded wait. + match unsafe { WaitForSingleObject(process.as_raw_handle(), timeout_ms) } { + WAIT_OBJECT_0 => Ok(true), + WAIT_TIMEOUT => Ok(false), + WAIT_FAILED => Err(std::io::Error::last_os_error()), + _ => Err(std::io::Error::other("unexpected process wait result")), + } + } + /// A Run session like `cmd -> mvn -> java`, owned the way /// `start_reserved_process` owns one: the suspended root joins its tree /// before ping can start. #[cfg(windows)] - fn spawn_owned_process_tree( - window: &str, - session: &str, - title: &str, - ) -> (Child, std::process::ChildStdout, u32) { + fn spawn_owned_process_tree(window: &str, session: &str, title: &str) -> OwnedRunTree { + use std::os::windows::io::{FromRawHandle, OwnedHandle}; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE}; let mut command = Command::new("cmd"); command .args(["/C", "ping -n 30 127.0.0.1"]) @@ -3739,18 +3805,10 @@ mod tests { ProcessTree::prepare(&mut command); let mut child = command.spawn().expect("spawn service"); let tree = ProcessTree::adopt(&mut child).expect("own service tree"); - // cmd writes nothing itself; the first output byte comes from ping, so - // the descendant is running once it arrives. The pipe stays open for - // the test, otherwise ping would exit on its next write. let mut stdout = child.stdout.take().expect("service output"); - stdout - .read_exact(&mut [0; 1]) - .expect("service descendant output"); - let descendant = *child_process_ids(child.id()) - .first() - .expect("service descendant"); + let key = run_session_key(window, session); sessions().lock().unwrap().insert( - run_session_key(window, session), + key.clone(), RunningSession { pid: child.id(), execution_id: Some(format!("{session}-execution")), @@ -3761,7 +3819,49 @@ mod tests { tree, }, ); - (child, stdout, descendant) + let mut fixture = OwnedRunTree { + key, + root: child, + descendant: None, + output: None, + }; + // cmd writes nothing itself; ping's first byte signals readiness. Bound + // the pipe read and retain its returned pipe so ping does not exit early. + let (sender, receiver) = std::sync::mpsc::channel(); + let reader = thread::spawn(move || { + let result = stdout.read_exact(&mut [0; 1]); + let _ = sender.send((result, stdout)); + }); + let (result, stdout) = match receiver.recv_timeout(Duration::from_secs(2)) { + Ok(ready) => ready, + Err(error) => { + // Closing this fixture's job releases the blocked pipe reader. + drop(fixture); + if receiver.recv_timeout(Duration::from_secs(2)).is_ok() { + reader.join().expect("join stopped fixture reader"); + } + panic!("service descendant readiness: {error}"); + } + }; + reader.join().expect("join ready fixture reader"); + fixture.output = Some(stdout); + result.expect("service descendant output"); + let descendant = *child_process_ids(fixture.root.id()) + .first() + .expect("service descendant"); + // Capture identity before stopping the job: PID snapshots can still list + // an exited process, and a later lookup can race PID reuse. + // SAFETY: PROCESS_SYNCHRONIZE grants only waiting; this opens a + // non-inheritable handle for the descendant we observed running. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, descendant) }; + assert!( + !handle.is_null(), + "open descendant: {}", + std::io::Error::last_os_error() + ); + // SAFETY: the non-null handle is transferred once and closed by OwnedHandle. + fixture.descendant = Some(unsafe { OwnedHandle::from_raw_handle(handle) }); + fixture } #[cfg(windows)] @@ -3776,32 +3876,21 @@ mod tests { .collect() } - #[cfg(windows)] - fn process_is_alive(pid: u32) -> bool { - let mut system = sysinfo::System::new(); - system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - system.process(sysinfo::Pid::from_u32(pid)).is_some() - } - #[cfg(windows)] #[test] fn window_release_ends_its_process_trees_and_keeps_other_windows() { // Regression for #1137: closing a window left Run services and their // descendants running. Another window's service must survive the close. - let (mut closed, _closed_output, closed_descendant) = - spawn_owned_process_tree("release-closed-window", "service", "Closed"); - let (mut kept, _kept_output, kept_descendant) = - spawn_owned_process_tree("release-kept-window", "service", "Kept"); + let mut closed = spawn_owned_process_tree("release-closed-window", "service", "Closed"); + let mut kept = spawn_owned_process_tree("release-kept-window", "service", "Kept"); stop_sessions( take_owned_sessions(|key| key.window_label == "release-closed-window"), RUN_STOP_TIMEOUT, ) .expect("the closed window's trees end"); - assert!(closed.wait().is_ok()); - assert!(!process_is_alive(closed_descendant)); - assert!(kept.try_wait().unwrap().is_none()); - assert!(process_is_alive(kept_descendant)); + closed.assert_ended(); + kept.assert_running(); // A second destroy/exit pass finds nothing left and must not reap again. assert!(take_owned_sessions(|key| key.window_label == "release-closed-window").is_empty()); @@ -3811,8 +3900,7 @@ mod tests { RUN_STOP_TIMEOUT, ) .expect("the kept window's trees end"); - assert!(kept.wait().is_ok()); - assert!(!process_is_alive(kept_descendant)); + kept.assert_ended(); } #[cfg(windows)] @@ -3820,13 +3908,11 @@ mod tests { fn dropping_a_session_ends_its_tree_like_a_crashed_lithe() { // When Lithe dies, Windows closes its job handles. Dropping the session // closes the handle the same way, so no stop call is involved. - let (mut root, _output, descendant) = - spawn_owned_process_tree("crash-window", "service", "Crash"); + let mut root = spawn_owned_process_tree("crash-window", "service", "Crash"); let owned = take_owned_sessions(|key| key.window_label == "crash-window"); assert_eq!(owned.len(), 1); drop(owned); - assert!(root.wait().is_ok()); - assert!(!process_is_alive(descendant)); + root.assert_ended(); } #[cfg(windows)] @@ -3835,11 +3921,9 @@ mod tests { // Maven goals and services of closed projects have no webview store, // so the close confirmation must come from the native table. let window = "listing-window"; - let (mut first, _first_output, _) = - spawn_owned_process_tree(window, "maven", "clean install"); - let (mut second, _second_output, _) = spawn_owned_process_tree(window, "service", "Api"); - let (mut other, _other_output, _) = - spawn_owned_process_tree("listing-other-window", "service", "Other"); + let mut first = spawn_owned_process_tree(window, "maven", "clean install"); + let mut second = spawn_owned_process_tree(window, "service", "Api"); + let mut other = spawn_owned_process_tree("listing-other-window", "service", "Other"); let preparing = { let mut pending = pending_launches().lock().unwrap(); let reservation = reserve_test_launch(&mut pending, window, "prepare"); @@ -3879,7 +3963,7 @@ mod tests { .expect("listing fixtures end"); } for root in [&mut first, &mut second, &mut other] { - assert!(root.wait().is_ok()); + root.assert_ended(); } } From f0072c970ec598bc2d1f49566e5dcbf2ee705261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=81=E4=BA=8C=E7=BF=BC=E5=A0=95=E8=90=BD=E5=A4=A9?= =?UTF-8?q?=E4=BD=BF?= <61737634+icefery@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:48:59 +0800 Subject: [PATCH 08/50] =?UTF-8?q?fix(macos):=20=E7=BB=88=E7=AB=AF=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E5=90=8C=E6=AD=A5:=20=E4=B8=BB=E9=A2=98=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E3=80=81COLORFGBG=20=E4=B8=8E=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=88=B6=E8=83=8C=E6=99=AF=E9=80=9A=E7=9F=A5=20(#1448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): 刷新已打开终端的主题色 * feat(macos): 终端环境导出 COLORFGBG - `COLORFGBG` 是 Terminal.app、iTerm2、tmux 等终端的行业惯例环境变量;omp 等 TUI 在进程启动时同步读取其 bg 字段(<8 深、>=8 浅)选择配色,先于任何 OSC 往返,可消除首帧按回退深色渲染的闪变。 - Lithe 终端此前未导出该变量;且终端视图加入窗口前 effectiveAppearance 跟随系统外观,直接读它会与 Lithe 主题不一致。 - `TerminalTransport` 协议新增 `reportsLightBackground: Bool?`(宿主是否渲染浅色背景,无法判定时为 nil),extension 默认 nil - `TerminalSession` 以可选值直赋导出 `COLORFGBG`(浅色 `0;15`、深色 `15;0`;fg;bg 惯例,判读位是 bg 字段),宿主无法判定时同时清除从外层终端继承的同名值 - macOS 实现按应用主题偏好解析:视图未挂载窗口时用主题 pin 的明暗,挂载后用窗口外观 - `TerminalModuleTests` 新增 4 个用例(浅色、深色、无法判定不写入、清除继承值);更新 `terminal-shell.json` 能力矩阵 - `TerminalModuleTests` 全绿(含 4 个新增) - 平台能力矩阵门禁通过 - 真机浅色/深色首帧配色正确,无闪变;`echo $COLORFGBG` 输出 `0;15` / `15;0` * fix(macos): 背景通知仅推送给订阅 kitty DEC mode 2031 的程序 - 背景变化通知由无条件推送改为按订阅发送: 解析进程输出中的 CSI ? 2031 h/l (含组合 DEC mode 与跨块分片), 未订阅的 shell 不再收到响应字节 - DSR 997 按背景亮度如实报告: 997;1n 暗色、997;2n 亮色 - 新进程启动与终端 RIS 重置清除订阅状态 - 单测 15 用例: 通知编码门控与明暗状态 + 订阅解析全路径 --- .../Ports/WorkbenchBackgroundRendering.swift | 6 - macos/Sources/Lithe/LitheApp.swift | 1 + .../Models/AppModel/AppModel+Terminal.swift | 8 +- .../MacOS/Terminal/MacTerminalTransport.swift | 148 +++++++++++-- .../Views/Terminal/TerminalSurfaceView.swift | 38 +++- .../Application/TerminalFeatureModel.swift | 12 +- .../Ports/TerminalTransport.swift | 11 + .../Runtime/TerminalSession.swift | 3 + .../TerminalModuleTests.swift | 67 ++++++ .../Tests/LitheTests/BundledUIFontTests.swift | 2 +- .../MacTerminalBackgroundReportingTests.swift | 125 ++++++++++- .../LitheTests/TerminalInputFocusTests.swift | 199 ++++++++++++++++-- .../TerminalPlacementFeatureModelTests.swift | 9 +- .../features/terminal-canvas-compositing.json | 5 +- .../features/terminal-shell.json | 2 +- 15 files changed, 569 insertions(+), 67 deletions(-) delete mode 100644 macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift diff --git a/macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift b/macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift deleted file mode 100644 index a4b0d0404f..0000000000 --- a/macos/Sources/Lithe/Core/Ports/WorkbenchBackgroundRendering.swift +++ /dev/null @@ -1,6 +0,0 @@ -/// Native surfaces that can reveal the workbench wallpaper behind their content. -/// Platform-native views own the concrete rendering and restoration behavior. -@MainActor -protocol WorkbenchBackgroundRendering: AnyObject { - func setWorkbenchBackgroundVisible(_ isVisible: Bool) -} diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index a611051925..e7e860ef51 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -1314,6 +1314,7 @@ extension AppThemePreference { case .dark: true } } + var preferredColorScheme: ColorScheme? { switch self { case .system: nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index e16e83895d..256cb8c787 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -61,7 +61,8 @@ extension AppModel { let session = feature.createSession( in: workspaceURL, shellPath: shellPath ?? settings.terminalShellPath, - preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent } + preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent }, + showsWorkbenchBackground: workbenchBackgroundFeature.hasImage ) configureTerminalSession(session) terminalPlacementFeature.registerSession(session.id) @@ -131,7 +132,10 @@ extension AppModel { TerminalEnvironmentChange(name: $0.name, value: $0.value) } ) - let created = try feature.createProcessSession(launch) { [weak self] output in + let created = try feature.createProcessSession( + launch, + showsWorkbenchBackground: workbenchBackgroundFeature.hasImage + ) { [weak self] output in self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output) } configureTerminalSession(created.session) diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 4f560c7f18..a518205334 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -17,6 +17,7 @@ final class LitheTerminalView: LocalProcessTerminalView { /// App theme light/dark preference while the view is not yet in a window; nil follows the system appearance. var preferredAppearanceIsDark: (() -> Bool?)? private var lastReportedBackgroundPayload: String? + private var backgroundChangeNotifications = MacTerminalBackgroundSubscription() override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -158,19 +159,20 @@ final class LitheTerminalView: LocalProcessTerminalView { override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() - applyThemeColors() + applyThemeColors(theme: LitheTheme.activeTheme) } - func applyThemeColors() { - // Outside a window, effectiveAppearance follows the system; the app theme decides the reported background. + func applyThemeColors(theme: AppColorTheme, isDark requestedIsDark: Bool? = nil) { let isDark: Bool - if window == nil, let preferredAppearanceIsDark { + if let requestedIsDark { + isDark = requestedIsDark + } else if window == nil, let preferredAppearanceIsDark { isDark = preferredAppearanceIsDark() ?? (effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua) } else { isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } - let editorColor = LitheTheme.nsColor(.editor, isDark: isDark) + let editorColor = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark) let palette = MacTerminalPalette( preferences: UserDefaults.standard.persistentDomain(forName: "com.apple.Terminal"), backgroundColor: editorColor @@ -192,7 +194,7 @@ final class LitheTerminalView: LocalProcessTerminalView { // Terminal.app profiles may use a foreground color that assumes their // own opaque background. The terminal is composited over Lithe's // editor or wallpaper, so keep the default text tied to the app theme. - nativeForegroundColor = LitheTheme.nsColor(.primaryText, isDark: isDark) + nativeForegroundColor = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark) caretColor = isDark ? NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1) : NSColor(srgbRed: 0.18, green: 0.43, blue: 0.79, alpha: 1) @@ -212,38 +214,51 @@ final class LitheTerminalView: LocalProcessTerminalView { reportBackgroundChangeIfNeeded() } - /// On reported-background change, sends the OSC 11 answer plus kitty DSR 997; trackers re-query, others ignore. + /// Reports background changes only to programs subscribed to kitty DEC mode 2031. private func reportBackgroundChangeIfNeeded() { let payload = MacTerminalBackgroundReporting.osc11Payload(for: nativeBackgroundColor) guard payload != lastReportedBackgroundPayload else { return } lastReportedBackgroundPayload = payload - guard process.running else { return } - terminal.sendResponse(text: MacTerminalBackgroundReporting.changeNotification(for: nativeBackgroundColor)) + guard process.running, + let notification = MacTerminalBackgroundReporting.changeNotification( + for: nativeBackgroundColor, + isSubscribed: backgroundChangeNotifications.isSubscribed + ) else { return } + terminal.sendResponse(text: notification) } - func setWorkbenchBackgroundVisible(_ isVisible: Bool) { - guard showsWorkbenchBackground != isVisible else { return } + func updateWorkbenchSurface(isVisible: Bool) { showsWorkbenchBackground = isVisible - applyThemeColors() + applyThemeColors(theme: LitheTheme.activeTheme) } + func updateWorkbenchSurface(isVisible: Bool, theme: AppColorTheme, isDark: Bool) { + showsWorkbenchBackground = isVisible + applyThemeColors(theme: theme, isDark: isDark) + } + + override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { onOpenLink?(link, params) } override func dataReceived(slice: ArraySlice) { + backgroundChangeNotifications.feed(slice) onProcessOutput?(Data(slice)) super.dataReceived(slice: slice) } + func resetBackgroundChangeNotifications() { + backgroundChangeNotifications.reset() + } + deinit { NotificationCenter.default.removeObserver(self) } } -extension LitheTerminalView: WorkbenchBackgroundRendering {} -/// Report pushed when the background changes: SwiftTerm's own OSC 11 answer plus the kitty DSR 997 notification. +/// Reports changed backgrounds to subscribers; kitty DSR 997 uses 1 for dark and 2 for light. enum MacTerminalBackgroundReporting { static func osc11Payload(for color: NSColor) -> String { let srgb = color.usingColorSpace(.sRGB) ?? color @@ -255,8 +270,99 @@ enum MacTerminalBackgroundReporting { return "rgb:\(channel(red))/\(channel(green))/\(channel(blue))" } - static func changeNotification(for color: NSColor) -> String { - "\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;2n" + static func reportsDarkBackground(for color: NSColor) -> Bool { + let srgb = color.usingColorSpace(.sRGB) ?? color + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 1 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + func linearize(_ component: CGFloat) -> Double { + let value = min(1, max(0, Double(component))) + return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + let luminance = 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue) + return luminance < 0.5 + } + + static func changeNotification(for color: NSColor, isSubscribed: Bool) -> String? { + guard isSubscribed else { return nil } + let backgroundState = reportsDarkBackground(for: color) ? 1 : 2 + return "\u{1b}]11;\(osc11Payload(for: color))\u{1b}\\\u{1b}[?997;\(backgroundState)n" + } +} + +/// Tracks kitty DEC mode 2031 so change notifications are sent only to programs that requested them. +struct MacTerminalBackgroundSubscription { + private(set) var isSubscribed = false + private var pendingEscape: [UInt8] = [] + + private static let escape = UInt8(0x1b) + private static let mode2031: [UInt8] = [0x32, 0x30, 0x33, 0x31] + private static let altScreenModes: [[UInt8]] = [ + [0x34, 0x37], + [0x31, 0x30, 0x34, 0x37], + [0x31, 0x30, 0x34, 0x39], + ] + private static let maximumSequenceLength = 64 + + init() { + pendingEscape.reserveCapacity(Self.maximumSequenceLength) + } + + mutating func feed(_ bytes: S) where S.Element == UInt8 { + for byte in bytes { + if pendingEscape.isEmpty { + if byte == Self.escape { pendingEscape.append(byte) } + continue + } + if byte == Self.escape { + pendingEscape = [Self.escape] + continue + } + pendingEscape.append(byte) + if pendingEscape == [Self.escape, 0x63] { + isSubscribed = false + pendingEscape.removeAll(keepingCapacity: true) + continue + } + if pendingEscape.count == 2 { + if byte != 0x5b { pendingEscape.removeAll(keepingCapacity: true) } + continue + } + if pendingEscape.count == 3 { + if byte != 0x3f { pendingEscape.removeAll(keepingCapacity: true) } + continue + } + if (0x40...0x7e).contains(byte) { + if byte == 0x68 || byte == 0x6c, + contains(Self.mode2031, in: pendingEscape[3..<(pendingEscape.count - 1)]) { + isSubscribed = byte == 0x68 + } else if byte == 0x6c, + leavesAltScreen(pendingEscape[3..<(pendingEscape.count - 1)]) { + // A subscribed TUI runs inside the alternate screen; when it leaves, the shell must not inherit pushes. + isSubscribed = false + } + pendingEscape.removeAll(keepingCapacity: true) + } else if pendingEscape.count >= Self.maximumSequenceLength { + pendingEscape.removeAll(keepingCapacity: true) + } + } + } + + mutating func reset() { + isSubscribed = false + pendingEscape.removeAll(keepingCapacity: true) + } + + private func contains(_ parameter: [UInt8], in parameters: ArraySlice) -> Bool { + var start = parameters.startIndex + for index in parameters.indices where parameters[index] == 0x3b { + if parameters[start..) -> Bool { + Self.altScreenModes.contains { contains($0, in: parameters) } } } @@ -428,6 +534,13 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L var nativeView: AnyObject { view } + func configureInitialSurface(showsWorkbenchBackground: Bool) { + view.updateWorkbenchSurface(isVisible: showsWorkbenchBackground) + } + + // Keep the exported COLORFGBG in sync with the OSC 11 answer: wallpaper transparency reports black, so it must export dark. + var reportsLightBackground: Bool? { !MacTerminalBackgroundReporting.reportsDarkBackground(for: view.nativeBackgroundColor) } + private let scrollbackLines: Int init(scrollbackLines: Int = 2_000, preferredAppearanceIsDark: (() -> Bool?)? = nil) { @@ -444,7 +557,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } view.font = Self.preferredTerminalFont() view.preferredAppearanceIsDark = preferredAppearanceIsDark - view.applyThemeColors() + view.applyThemeColors(theme: LitheTheme.activeTheme) view.allowMouseReporting = true view.linkReporting = .implicit view.linkHighlightMode = .hoverWithModifier @@ -526,6 +639,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L selectedShellPath = executablePath view.terminal.resetToInitialState() + view.resetBackgroundChangeNotifications() var options = view.terminal.options options.termName = environment["TERM"] ?? "xterm-256color" view.terminal.options = options diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift index d398beb4f9..a12414d778 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift @@ -4,7 +4,9 @@ import LitheTerminalModule struct TerminalSurfaceView: View { @ObservedObject var session: TerminalSession + @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings @State private var focusRequestID = 0 var body: some View { @@ -13,6 +15,8 @@ struct TerminalSurfaceView: View { TerminalNativeSurface( nativeView: nativeView, focusRequestID: focusRequestID, + theme: settings.colorTheme, + isDark: colorScheme == .dark, showsWorkbenchBackground: model.workbenchBackgroundFeature.hasImage ) } else { @@ -71,24 +75,38 @@ struct TerminalSurfaceView: View { private struct TerminalNativeSurface: NSViewRepresentable { let nativeView: NSView let focusRequestID: Int + let theme: AppColorTheme + let isDark: Bool let showsWorkbenchBackground: Bool - func makeCoordinator() -> Coordinator { - Coordinator() - } - + func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> TerminalSurfaceHostView { let hostView = TerminalSurfaceHostView(frame: .zero) hostView.attach(nativeView) - (nativeView as? WorkbenchBackgroundRendering)? - .setWorkbenchBackgroundVisible(showsWorkbenchBackground) + (nativeView as? LitheTerminalView)?.updateWorkbenchSurface( + isVisible: showsWorkbenchBackground, + theme: theme, + isDark: isDark + ) + context.coordinator.lastTheme = theme + context.coordinator.lastIsDark = isDark + context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground return hostView } func updateNSView(_ hostView: TerminalSurfaceHostView, context: Context) { hostView.attach(nativeView) - (nativeView as? WorkbenchBackgroundRendering)? - .setWorkbenchBackgroundVisible(showsWorkbenchBackground) + if context.coordinator.lastTheme != theme || context.coordinator.lastIsDark != isDark + || context.coordinator.lastShowsWorkbenchBackground != showsWorkbenchBackground { + (nativeView as? LitheTerminalView)?.updateWorkbenchSurface( + isVisible: showsWorkbenchBackground, + theme: theme, + isDark: isDark + ) + context.coordinator.lastTheme = theme + context.coordinator.lastIsDark = isDark + context.coordinator.lastShowsWorkbenchBackground = showsWorkbenchBackground + } guard context.coordinator.lastFocusRequestID != focusRequestID else { return } context.coordinator.lastFocusRequestID = focusRequestID DispatchQueue.main.async { @@ -106,9 +124,13 @@ private struct TerminalNativeSurface: NSViewRepresentable { final class Coordinator: NSObject { var lastFocusRequestID = -1 + var lastTheme: AppColorTheme? + var lastIsDark: Bool? + var lastShowsWorkbenchBackground: Bool? } } + private final class TerminalSurfaceHostView: NSView { private weak var terminalView: NSView? diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index b7de6ac4f4..41267cb7f3 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -48,9 +48,12 @@ public final class TerminalFeatureModel: ObservableObject { public func createSession( in workspaceURL: URL, shellPath: String? = nil, - preferredToolTabTitle: String? = nil + preferredToolTabTitle: String? = nil, + showsWorkbenchBackground: Bool = false ) -> TerminalSession { - let session = TerminalSession(transport: terminalFactory(), preferredToolTabTitle: preferredToolTabTitle) + let transport = terminalFactory() + transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + let session = TerminalSession(transport: transport, preferredToolTabTitle: preferredToolTabTitle) session.start(in: workspaceURL, shellPath: shellPath) terminalSessions.append(session) activeTerminalSessionID = session.id @@ -60,9 +63,12 @@ public final class TerminalFeatureModel: ObservableObject { @discardableResult public func createProcessSession( _ launch: TerminalProcessLaunch, + showsWorkbenchBackground: Bool = false, onOutput: ((String) -> Void)? = nil ) throws -> (session: TerminalSession, processID: Int32) { - let session = TerminalSession(transport: terminalFactory()) + let transport = terminalFactory() + transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + let session = TerminalSession(transport: transport) session.onOutput = onOutput let processID = try session.startProcess(launch) terminalSessions.append(session) diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift index d928618cb3..3e4c7eb30b 100644 --- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift +++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -70,6 +70,14 @@ public protocol TerminalTransport: AnyObject { var onTitle: ((String) -> Void)? { get set } var onDirectoryUpdate: ((String?) -> Void)? { get set } var onLink: ((String, [String: String]) -> Void)? { get set } + /// Whether the host terminal renders a light background, advertised through `COLORFGBG` + /// so TUIs pick a palette without querying; hosts that cannot tell keep it unset. + var reportsLightBackground: Bool? { get } + + /// Applies the host surface state to the native view before the shell + /// starts, so the exported `COLORFGBG` matches the OSC 11 answer the + /// mounted view will report. Hosts without wallpaper keep it a no-op. + func configureInitialSurface(showsWorkbenchBackground: Bool) func defaultShellPath() -> String func defaultEnvironment() -> [String: String] @@ -94,4 +102,7 @@ public extension TerminalTransport { var termProgramVersion: String? { nil } func stopAndWait() async { stop() } func runningPrograms() -> [TerminalRunningProgram] { [] } + var reportsLightBackground: Bool? { nil } + /// Hosts without wallpaper keep the initial surface configuration a no-op. + func configureInitialSurface(showsWorkbenchBackground: Bool) {} } diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift index 9f06b088f4..a33fbcbdd0 100644 --- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift +++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -180,6 +180,9 @@ public final class TerminalSession: ObservableObject, Identifiable { environment["TERM_PROGRAM"] = "Lithe" // Direct assignment drops any value inherited from the outer terminal when the transport cannot report one. environment["TERM_PROGRAM_VERSION"] = transport.termProgramVersion + // fg;bg per the xterm convention; consumers like oh-my-pi read the bg field (<8 dark, >=8 light). + // Direct assignment drops any value inherited from the outer terminal when the host cannot report one. + environment["COLORFGBG"] = transport.reportsLightBackground.map { $0 ? "0;15" : "15;0" } for change in changes { if let value = change.value { environment[change.name] = value diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index 002cdf1b7b..d609c64e4f 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -117,6 +117,69 @@ struct TerminalModuleTests { #expect(transport.stopCount == 1) } + @Test + func interactiveShellEnvironmentExportsColorFgbgForLightBackground() throws { + let transport = TestTransport() + transport.reportsLightBackground = true + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == "0;15") + } + + @Test + func interactiveShellEnvironmentExportsColorFgbgForDarkBackground() throws { + let transport = TestTransport() + transport.reportsLightBackground = false + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == "15;0") + } + + @Test + func interactiveShellEnvironmentOmitsColorFgbgWhenHostCannotTell() throws { + let transport = TestTransport() + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == nil) + } + + @Test + func createSessionAppliesWallpaperStateBeforeSpawn() throws { + let transport = TestTransport() + transport.reportsLightBackground = true + let feature = TerminalFeatureModel(terminalFactory: { transport }) + defer { feature.stopAllSessions() } + let session = feature.createSession( + in: URL(fileURLWithPath: "/workspace"), + shellPath: "/bin/zsh", + showsWorkbenchBackground: true + ) + defer { session.stop() } + + #expect(transport.shellEnvironments.first?["COLORFGBG"] == "15;0") + } + + @Test + func interactiveShellEnvironmentClearsInheritedColorFgbgWhenHostCannotTell() throws { + let transport = TestTransport() + transport.inheritedEnvironment["COLORFGBG"] = "7;0" + let session = TerminalSession(transport: transport) + defer { session.stop() } + session.start(in: URL(fileURLWithPath: "/workspace"), shellPath: "/bin/zsh") + + #expect(session.isRunning) + #expect(transport.shellEnvironments.first?["COLORFGBG"] == nil) + } + @Test func managedProcessForwardsInputToItsOwnPTY() throws { let transport = TestTransport() @@ -241,6 +304,7 @@ private final class TestTransport: TerminalTransport { var termProgramVersion: String? var shellStarts: [String] = [] var shellEnvironments: [[String: String]] = [] + var reportsLightBackground: Bool? var inheritedEnvironment: [String: String] = ["REMOVE_ME": "old"] var startError: Error? var onTermination: ((Int32?) -> Void)? @@ -253,6 +317,9 @@ private final class TestTransport: TerminalTransport { var processEnvironments: [[String: String]] = [] var sentInputs: [String] = [] var outputOnStart: String? + func configureInitialSurface(showsWorkbenchBackground: Bool) { + if showsWorkbenchBackground { reportsLightBackground = false } + } func defaultShellPath() -> String { "/bin/zsh" } func defaultEnvironment() -> [String: String] { inheritedEnvironment } func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { diff --git a/macos/Tests/LitheTests/BundledUIFontTests.swift b/macos/Tests/LitheTests/BundledUIFontTests.swift index 7be0ace3d5..bba98f18a9 100644 --- a/macos/Tests/LitheTests/BundledUIFontTests.swift +++ b/macos/Tests/LitheTests/BundledUIFontTests.swift @@ -90,7 +90,7 @@ struct BundledUIFontTests { let prompt = "\u{1b}[38;2;154;52;142m\u{e0b6}\u{1b}[48;2;154;52;142m\u{1b}[37muser \u{1b}[38;2;154;52;142m\u{1b}[48;2;218;98;125m\u{e0b0}\u{1b}[37m …/Lithe-IDEA \u{1b}[38;2;218;98;125m\u{1b}[48;2;252;161;125m\u{e0b0}\u{1b}[37m \u{f418} codex/issue-1082 $ \u{1b}[38;2;252;161;125m\u{1b}[48;2;134;187;216m\u{e0b0}\u{1b}[37m \u{e76f} v1.3.12 \u{1b}[38;2;134;187;216m\u{1b}[48;2;51;101;138m\u{e0b0}\u{1b}[37m ♥ 01:23 \u{1b}[0m\u{1b}[38;2;51;101;138m\u{e0b0}\u{1b}[0m\r\n" for dark in [false, true] { terminal.view.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) - terminal.view.applyThemeColors() + terminal.view.applyThemeColors(theme: .lithe, isDark: dark) terminal.view.feed(text: "\u{1b}[2J\u{1b}[H" + prompt + prompt) let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1440, pixelsHigh: 300, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, diff --git a/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift b/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift index c35156e1d9..648a8389fd 100644 --- a/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift +++ b/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift @@ -2,7 +2,7 @@ import AppKit import Testing @testable import Lithe -/// Sequence emitted when the background changes: SwiftTerm's OSC 11 answer plus the kitty DSR 997 notification. +/// Notifications emitted on background changes for programs subscribed to kitty DEC mode 2031. @Suite("Terminal background change reporting") struct MacTerminalBackgroundReportingTests { @Test @@ -21,8 +21,127 @@ struct MacTerminalBackgroundReportingTests { } @Test - func changeNotificationAppendsKittyRequeryRequest() { - let notification = MacTerminalBackgroundReporting.changeNotification(for: .white) + func doesNotNotifyWhenUnsubscribed() { + #expect(MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: false) == nil) + } + + @Test + func changeNotificationReportsLightStateForLightBackground() { + let notification = MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: true) #expect(notification == "\u{1b}]11;rgb:ffff/ffff/ffff\u{1b}\\\u{1b}[?997;2n") } + + @Test + func changeNotificationReportsDarkStateForDarkBackground() { + let notification = MacTerminalBackgroundReporting.changeNotification(for: .black, isSubscribed: true) + #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") + } + + @Test + func changeNotificationReportsDarkStateForWallpaperTransparency() { + let transparentBlack = NSColor.black.withAlphaComponent(0) + let notification = MacTerminalBackgroundReporting.changeNotification(for: transparentBlack, isSubscribed: true) + #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") + } +} + +@Suite("Kitty background-change subscription") +struct MacTerminalBackgroundSubscriptionTests { + @Test + func startsUnsubscribed() { + #expect(!MacTerminalBackgroundSubscription().isSubscribed) + } + + @Test + func subscribesOnDecMode2031Set() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func subscribesWhenMode2031IsCombinedWithOtherDecModes() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?1;2031h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func doesNotSubscribeToModeWithSimilarNumber() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?20310h".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func unsubscribesOnDecMode2031Reset() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?2031l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func detectsMarkerSplitAcrossChunks() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?20".utf8) + #expect(!subscription.isSubscribed) + subscription.feed("31h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func terminalResetClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}c".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func processRestartClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.reset() + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen1049ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1049l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen47ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?47l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen1047ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1047l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreenEnterKeepsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1049h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func plainOutputStaysUnsubscribed() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("ls -la\r\nhello world".utf8) + #expect(!subscription.isSubscribed) + } } diff --git a/macos/Tests/LitheTests/TerminalInputFocusTests.swift b/macos/Tests/LitheTests/TerminalInputFocusTests.swift index ab42f79fd2..a5970482fb 100644 --- a/macos/Tests/LitheTests/TerminalInputFocusTests.swift +++ b/macos/Tests/LitheTests/TerminalInputFocusTests.swift @@ -1,9 +1,11 @@ import AppKit +import SwiftUI import MetalKit import os import SwiftTerm import Testing @testable import Lithe +import LitheTerminalModule @Suite("Terminal input focus") @MainActor @@ -36,6 +38,20 @@ struct TerminalInputFocusTests { #expect(missing.ansiColors.allSatisfy { $0 == nil }) } + @Test + func terminalPaletteRefreshesOnThemeChange() { + let terminal = LitheTerminalView(frame: .zero) + terminal.applyThemeColors(theme: .lithe, isDark: true) + let litheBackground = terminal.nativeBackgroundColor + let litheTerminalBackground = terminal.terminal.backgroundColor + + terminal.applyThemeColors(theme: .codex, isDark: true) + + #expect(terminal.nativeBackgroundColor != litheBackground) + #expect(terminal.terminal.backgroundColor != litheTerminalBackground) + #expect(terminal.nativeBackgroundColor == LitheTheme.nsColor(.editor, theme: .codex, isDark: true)) + } + @Test func terminalPaletteAppliesContrastGatePerImportedEntry() throws { func archive(_ color: NSColor) throws -> Data { @@ -315,29 +331,38 @@ struct TerminalInputFocusTests { } } -@Suite("Terminal canvas compositing") +@Suite("Terminal canvas compositing", .serialized) @MainActor struct TerminalCanvasCompositingTests { + @Test + func unmountedTerminalUsesAppAppearancePreference() { + let terminal = LitheTerminalView(frame: .zero) + terminal.appearance = NSAppearance(named: .darkAqua) + terminal.preferredAppearanceIsDark = { false } + + terminal.applyThemeColors(theme: .lithe) + + let expected = LitheTheme.nsColor(.editor, theme: .lithe, isDark: false) + #expect(terminal.window == nil) + #expect(terminal.nativeBackgroundColor.isEqual(expected)) + } + @Test func canvasLeavesSharedPaneBackgroundVisibleWithWallpaper() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) - terminal.appearance = NSAppearance(named: .aqua) - terminal.setWorkbenchBackgroundVisible(true) - terminal.applyThemeColors() + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 0) // Metal clear colors are premultiplied, so a transparent canvas clears to black rather than the workbench color. let clearColor = terminal.nativeBackgroundColor.usingColorSpace(.sRGB) #expect(clearColor?.redComponent == 0) #expect(clearColor?.greenComponent == 0) #expect(clearColor?.blueComponent == 0) - // The layer must stay clear as well: the Core Graphics fallback paints its background from this layer. #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: false) #expect(renderer.layer?.isOpaque == false) - // Focus changes repaint the paused Metal surface, so the decision must survive them too. terminal.hasFocus = false #expect(renderer.layer?.isOpaque == false) } @@ -347,7 +372,7 @@ struct TerminalCanvasCompositingTests { for name in [NSAppearance.Name.aqua, .darkAqua] { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: name) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: name == .darkAqua) let editorColor = LitheTheme.nsColor(.editor, isDark: name == .darkAqua) #expect(terminal.nativeBackgroundColor.alphaComponent == 1) #expect(terminal.nativeBackgroundColor.isEqual(editorColor)) @@ -355,7 +380,7 @@ struct TerminalCanvasCompositingTests { #expect(terminal.layer?.isOpaque == true) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: name == .darkAqua) #expect(renderer.layer?.isOpaque == true) } } @@ -364,18 +389,16 @@ struct TerminalCanvasCompositingTests { func canvasFollowsWorkbenchBackgroundChanges() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: .aqua) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: false) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - - terminal.setWorkbenchBackgroundVisible(true) + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 0) #expect(terminal.nativeBackgroundColor.usingColorSpace(.sRGB)?.redComponent == 0) #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) #expect(renderer.layer?.isOpaque == false) - - terminal.setWorkbenchBackgroundVisible(false) + terminal.updateWorkbenchSurface(isVisible: false, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 1) #expect(terminal.layer?.isOpaque == true) #expect(renderer.layer?.isOpaque == true) @@ -383,24 +406,124 @@ struct TerminalCanvasCompositingTests { @Test func osc11ReportedBackgroundMatchesCanvasClearColor() throws { - // SwiftTerm answers OSC 11 from the terminal background color, which mirrors the canvas clear color. - // Reporting black while a wallpaper is visible is the documented tradeoff of the premultiplied clear. let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: .aqua) - terminal.applyThemeColors() - let editorColor = LitheTheme.nsColor(.editor, isDark: false).usingColorSpace(.sRGB) + terminal.applyThemeColors(theme: .lithe, isDark: false) + let editorColor = LitheTheme.nsColor(.editor, theme: .lithe, isDark: false).usingColorSpace(.sRGB) let expected = try #require(editorColor) let reported = terminal.terminal.backgroundColor #expect(reported.red == UInt16((expected.redComponent * 65535).rounded())) #expect(reported.green == UInt16((expected.greenComponent * 65535).rounded())) #expect(reported.blue == UInt16((expected.blueComponent * 65535).rounded())) - - terminal.setWorkbenchBackgroundVisible(true) + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) let transparent = terminal.terminal.backgroundColor #expect(transparent.red == 0) #expect(transparent.green == 0) #expect(transparent.blue == 0) } + + @Test + func mountedTerminalRefreshesTheSameSurfaceInDarkAppearance() async throws { + try await assertMountedTerminalThemeRefresh(scheme: .dark) + } + + @Test + func mountedTerminalRefreshesTheSameSurfaceInLightAppearance() async throws { + try await assertMountedTerminalThemeRefresh(scheme: .light) + } + + private func assertMountedTerminalThemeRefresh(scheme: ColorScheme) async throws { + let domain = "Lithe.TerminalThemeRefresh.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + let originalTheme = AppThemeRuntime.shared.activeTheme + defer { + defaults.removePersistentDomain(forName: domain) + AppThemeRuntime.shared.activate(originalTheme) + } + let isDark = scheme == .dark + let store = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: store) + settings.themePreference = isDark ? .dark : .light + settings.colorTheme = .lithe + let model = AppModel(settings: settings, + services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) + let terminal = LitheTerminalView(frame: .zero) + let transport = ThemeRefreshTestTransport(view: terminal) + let session = TerminalSession(transport: transport) + session.start(in: URL(fileURLWithPath: "/workspace/theme-refresh"), shellPath: "/bin/zsh") + let host = NSHostingView(rootView: TerminalSurfaceView(session: session) + .environmentObject(model).environmentObject(settings).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua) + window.contentView = host + let initialBackground = LitheTheme.nsColor(.editor, theme: .lithe, isDark: isDark) + let initialForeground = LitheTheme.nsColor(.primaryText, theme: .lithe, isDark: isDark) + let initialDeadline = ContinuousClock().now.advanced(by: .seconds(2)) + while (!terminal.nativeBackgroundColor.isEqual(initialBackground) + || !terminal.nativeForegroundColor.isEqual(initialForeground)), + ContinuousClock().now < initialDeadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + #expect(terminal.nativeBackgroundColor.isEqual(initialBackground)) + #expect(terminal.nativeForegroundColor.isEqual(initialForeground)) + #expect(session.isRunning) + let clock = ContinuousClock() + for theme in [AppColorTheme.codex, .linear, .lithe] { + settings.colorTheme = theme + let expected = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark) + let expectedForeground = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark) + let deadline = clock.now.advanced(by: .seconds(2)) + while (!terminal.nativeBackgroundColor.isEqual(expected) + || !terminal.nativeForegroundColor.isEqual(expectedForeground)), + clock.now < deadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + let sRGB = try #require(expected.usingColorSpace(.sRGB)) + let reported = terminal.terminal.backgroundColor + let reportedForeground = terminal.terminal.foregroundColor + let expectedForegroundSRGB = try #require(expectedForeground.usingColorSpace(.sRGB)) + #expect(terminal.nativeBackgroundColor.isEqual(expected)) + #expect(terminal.nativeForegroundColor.isEqual(expectedForeground)) + #expect(reported.red == UInt16(sRGB.redComponent * 65535)) + #expect(reported.green == UInt16(sRGB.greenComponent * 65535)) + #expect(reported.blue == UInt16(sRGB.blueComponent * 65535)) + #expect(abs(Int(reportedForeground.red) - Int(expectedForegroundSRGB.redComponent * 65535)) <= 1) + #expect(abs(Int(reportedForeground.green) - Int(expectedForegroundSRGB.greenComponent * 65535)) <= 1) + #expect(abs(Int(reportedForeground.blue) - Int(expectedForegroundSRGB.blueComponent * 65535)) <= 1) + #expect(session.isRunning) + } + session.stop() + window.contentView = nil + window.close() + await model.shutdownProjectSession() + } +} +@MainActor +private final class ThemeRefreshTestTransport: TerminalTransport { + let nativeView: AnyObject + var isRunning = false + var processID: Int32? { isRunning ? 1234 : nil } + var shellName = "zsh" + var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? + var onTitle: ((String) -> Void)? + var onDirectoryUpdate: ((String?) -> Void)? + var onLink: ((String, [String: String]) -> Void)? + + init(view: LitheTerminalView) { nativeView = view } + func defaultShellPath() -> String { "/bin/zsh" } + func defaultEnvironment() -> [String: String] { [:] } + func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true } + func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 { isRunning = true; return 1234 } + func send(_ input: Data) throws {} + func interrupt() throws {} + func focus() {} + func clear() {} + func stop() { isRunning = false } } @MainActor @@ -447,3 +570,39 @@ private final class CursorDrawingRecorder: NSObject, MTKViewDelegate { private final class TerminalOtherResponderView: NSView { override var acceptsFirstResponder: Bool { true } } + +/// Appearance resolution behind the exported COLORFGBG and the OSC 11 answer. +@Suite("MacOS appearance resolution") +@MainActor +struct MacTerminalAppearanceResolutionTests { + @Test + func themePreferenceOverridesSystemAppearanceBeforeMount() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { true }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == false) + } + + @Test + func effectiveAppearanceUsedWhenNoPreference() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { nil }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == true) + } + + @Test + func wallpaperConfiguredBeforeSpawnReportsDarkAndAnswersBlack() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { nil }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == true) + transport.configureInitialSurface(showsWorkbenchBackground: true) + #expect(transport.reportsLightBackground == false) + // The canvas is transparent; OSC 11 answers this same black, so the env export stays consistent. + #expect(view.nativeBackgroundColor == NSColor.black.withAlphaComponent(0)) + } +} diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 0291ab1c58..94f7e27567 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -259,7 +259,8 @@ struct TerminalPlacementFeatureModelTests { let feature = try #require(model.terminalFeature) let store = WorkbenchToolContentStore() let host = NSHostingView(rootView: RetainedTerminalReviewHost(state: state, store: store, - model: model, feature: feature).environment(\.colorScheme, scheme)) + model: model, feature: feature).environmentObject(model).environmentObject(model.settings) + .environment(\.colorScheme, scheme)) host.sizingOptions = [] host.frame = NSRect(x: 0, y: 0, width: 480, height: 400) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), @@ -343,9 +344,9 @@ struct TerminalPlacementFeatureModelTests { model.selectTerminalSession(b) let store = WorkbenchToolContentStore() let host = NSHostingView(rootView: VStack(spacing: 0) { - TerminalSurfaceView(session: c).environmentObject(model).frame(height: 160) + TerminalSurfaceView(session: c).environmentObject(model).environmentObject(model.settings).frame(height: 160) RetainedTerminalReviewHost(state: state, store: store, model: model, feature: feature) - }) + }.environmentObject(model).environmentObject(model.settings)) host.sizingOptions = [] let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), styleMask: [.borderless], backing: .buffered, defer: false) @@ -558,7 +559,7 @@ private struct RetainedTerminalReviewHost: View { Color(nsColor: NSColor(srgbRed: 0.8, green: 0.1, blue: 0.6, alpha: 1)) if state.visible { RetainedWorkbenchToolContent(store: store, tool: .terminal, - content: TerminalView(feature: feature).environmentObject(model), + content: TerminalView(feature: feature).environmentObject(model).environmentObject(model.settings), onActivate: { model.activeToolTerminalSession?.focus() }) .environment(\.workbenchHasBackgroundImage, state.hasImage) } diff --git a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json index ecc9f0dfe3..40986c2926 100644 --- a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json +++ b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json @@ -2,12 +2,13 @@ "id": "terminal-canvas-compositing", "area": "工作台", "group": "终端", - "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色),背景实际变化时向终端应用推送 OSC 11 报告与 kitty 变更通知 DSR 997", + "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色);已打开终端随 Color theme 变化刷新背景和前景及 OSC 11/10 应答;背景变化时仅向订阅 kitty DEC mode 2031 的终端应用推送 OSC 11 与 DSR 997,997;1n 报暗、997;2n 报亮", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift", + "macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift", "macos/Tests/LitheTests/TerminalInputFocusTests.swift", "macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift" ] @@ -20,5 +21,5 @@ ] }, "owner": "Terminal", - "verification": "亮色主题无壁纸时确认终端字缘无白色小框、正文不发浅,画布不透明且底色随明暗外观切换;开启壁纸后确认壁纸正常透出且字缘干净。OSC 11 探针无壁纸应答编辑器底色(亮色 rgb:ffff/ffff/ffff),有壁纸应答 rgb:0000/0000/0000。明暗切换或开关壁纸时,已运行且跟踪背景的 TUI(如 oh-my-pi)应无需重启即跟随重查;不跟踪的程序不受推送影响。已知取舍:SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,有壁纸时报告黑色;若上游改为预乘清屏可恢复报告编辑器底色。" + "verification": "无壁纸时分别使用 Dark+Lithe、Dark+Codex、Dark+Linear,在同一已打开终端切换 Color theme 后查询 OSC 11,确认分别报告 rgb:1919/1a1a/1c1c、rgb:1111/1111/1111、rgb:0f0f/0f0f/1111;Light 模式分别查询 OSC 11 和 OSC 10,确认 Lithe 与 Codex 的 OSC 11 都为白色、Linear 为 rgb:fcfc/fcfc/fdfd,OSC 10 可区分 Lithe rgb:1f3b/2312/2937、Codex rgb:0d0d/0d0d/0d0d、Linear rgb:1b1b/1b1b/1b1b。Dark OSC 10 前景依次应为 Lithe rgb:dfff/e1c9/e5e2、Codex rgb:fcfc/fcfc/fcfc、Linear rgb:e3e3/e4e4/e6e6。切换主题后 shell PID 不变;开启壁纸后确认壁纸透出、字缘干净且 OSC 11 报告 rgb:0000/0000/0000。明暗切换或开关壁纸时,仅已运行且订阅 kitty DEC mode 2031 的 TUI(如 oh-my-pi)应收到背景变化通知并无需重启即跟随重查;未订阅的 zsh/cat 切换时不得收到回显字节;验证订阅、退订、跨输出块切分及终端 RIS 重置;DSR 997 暗色发送 997;1n、亮色发送 997;2n。亮色主题下同时确认字缘无白色小框、正文不发浅。SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,因此有壁纸时按既有取舍报告黑色;有壁纸时 COLORFGBG 导出深色 15;0,与 OSC 11 黑色应答和 DSR 997;1n 同源一致;若上游改为预乘清屏可恢复报告编辑器底色。" } diff --git a/shared/platform-feature-matrix/features/terminal-shell.json b/shared/platform-feature-matrix/features/terminal-shell.json index 767c980f94..c1eb9acdbf 100644 --- a/shared/platform-feature-matrix/features/terminal-shell.json +++ b/shared/platform-feature-matrix/features/terminal-shell.json @@ -30,5 +30,5 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效;macOS 新建终端下拉框复用 Project 下拉框的共享菜单样式,菜单操作与原有 Shell 选择、重新检测功能一致;验证环境变量、工作目录和不可用 Shell 的提示。 设置页终端分组新增回滚行数配置(默认 2000,范围 100–50000,步进 500),读取、写入、恢复默认均做上下限夹取,对新建会话生效(TerminalScrollbackSettingsTests);设置搜索按界面名称 Terminal scrollback/终端回滚行数/行数 均可命中终端页(SettingsSearchVocabularyTests)。 macOS 终端环境随 TERM_PROGRAM=Lithe 一并导出 TERM_PROGRAM_VERSION(取应用打包版本,无法取得时不写入),与 Windows 端一致,供工具按版本门控终端能力(TerminalModuleTests)。" + "verification": "验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效;macOS 新建终端下拉框复用 Project 下拉框的共享菜单样式,菜单操作与原有 Shell 选择、重新检测功能一致;验证环境变量、工作目录和不可用 Shell 的提示。 设置页终端分组新增回滚行数配置(默认 2000,范围 100–50000,步进 500),读取、写入、恢复默认均做上下限夹取,对新建会话生效(TerminalScrollbackSettingsTests);设置搜索按界面名称 Terminal scrollback/终端回滚行数/行数 均可命中终端页(SettingsSearchVocabularyTests)。 macOS 终端环境随 TERM_PROGRAM=Lithe 一并导出 TERM_PROGRAM_VERSION(取应用打包版本,无法取得时不写入),与 Windows 端一致,供工具按版本门控终端能力(TerminalModuleTests);按当前外观导出 COLORFGBG(浅色 0;15、深色 15;0,宿主无法判定时不写入),TUI 无需 OSC 查询即可选择配色(TerminalModuleTests)。" } From 18345cc96a89e1af3b04b48aacd9af89350202cb Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 21:03:30 +0800 Subject: [PATCH 09/50] =?UTF-8?q?feat(vcs):=20Core=20=E4=B8=8E=20Node=20SD?= =?UTF-8?q?K=20=E5=AE=9E=E7=8E=B0=E6=8F=92=E4=BB=B6=20VCS=20Provider=20?= =?UTF-8?q?=E5=BA=95=E5=B1=82=EF=BC=88#827=20P1=EF=BC=89=20(#1430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vcs): Core 实现插件 VCS Provider 底层(#827 P1) - 插件清单 API 3 新增 vcsProviders 能力、process.vcs 权限和 onVcsRoot 激活事件,git 为保留 ID;安装审查以 vcsTool 列出 VCS 命令行 - 新增 vcs.detectRoots(静态标记识别根、歧义目录与用户映射、祖先工作副本)、vcs.ownerOf(最深根归属)、vcs.decodeReply(Provider 回复校验与归一化) - LSP 会话:只向插件会话提供 litheVcs 协商,成功后发出 vcsProvider 事件;lsp.vcsRequest 按操作生成 lithe/vcs/* 请求并检查已协商能力;Provider 错误码只保留契约白名单 - lithe/vcs/execution 转为会话日志事件,lithe/vcs/authenticate 暂按未知请求回绝 - 契约、插件 README/schema、rust-core-api、fixtures、功能矩阵与提议 Note 同步 - 修复 unregistering_a_running_function_revokes_the_call 的竞态:改为有界等待 $/cancelRequest 写出 * feat(sdk): Node SDK 支持 litheVcs 并用示例 Provider 端到端验证(#827 P1) - backend.vcs(id, handlers, traits) 按声明的处理函数协商 VCS 能力,必需 status/baseContent/checkin/rollback,git 保留,litheVcs 不能手写进 capabilities - 只在宿主提供 litheVcs 1 时分发 lithe/vcs/* 请求;回复必须是对象,无返回值视为 {} - VCS 处理函数的 context.run 自动发出 lithe/vcs/execution(命令行、退出码、耗时、截断到 16 KiB 的 stderr),可用 report: false 关闭 - 导出 VCS_ERRORS 与 isVcsId,补充类型声明、README 和单元测试 - 新增 sdk/node/examples/sample-vcs 玩具 VCS,Rust 端到端测试经真实 Core 会话跑通协商、全部 11 种操作、错误码和执行报告 - 契约写明 lithe/vcs/execution 的参数;Note 与功能矩阵更新 P1 完成状态 * fix(vcs): 按评审修正祖先根判断与示例移动回滚(#827 P1) - 只有全部由 .. 段组成的根 ID 才是工作区外的祖先根;工作区内名为 ..repo 的目录按普通路径计算深度和包含关系,补充单元测试 - 示例 Provider 回滚移动目标时把文件移回原路径、恢复基线内容并清除移动记录;端到端测试覆盖移动后回滚 - 插件能力版本笔记补充 vcsProviders、其对 serverProcess 的依赖、两端宿主尚未声明的状态,以及 litheVcs 协商 --- .../2026-10-17-plugin-capability-versions.md | 7 +- .../2026-10-22-unified-vcs-provider-model.md | 83 +- rust/lithe-core/src/lib.rs | 1 + rust/lithe-core/src/lsp/interface/client.rs | 30 + rust/lithe-core/src/lsp/interface/engine.rs | 464 ++++++++++- rust/lithe-core/src/lsp/interface/mod.rs | 1 + rust/lithe-core/src/lsp/interface/types.rs | 5 + .../src/lsp/interface/vcs_provider.rs | 183 +++++ rust/lithe-core/src/plugins/capabilities.rs | 4 + rust/lithe-core/src/plugins/declarative.rs | 90 ++- rust/lithe-core/src/protocol/command.rs | 13 + rust/lithe-core/src/runtime/dispatcher.rs | 24 + rust/lithe-core/src/tests/mod.rs | 1 + rust/lithe-core/src/tests/vcs.rs | 401 ++++++++++ rust/lithe-core/src/vcs/commands.rs | 263 +++++++ rust/lithe-core/src/vcs/manifest.rs | 286 +++++++ rust/lithe-core/src/vcs/mod.rs | 29 + rust/lithe-core/src/vcs/model.rs | 726 ++++++++++++++++++ rust/lithe-core/src/vcs/requests.rs | 503 ++++++++++++ rust/lithe-core/src/vcs/roots.rs | 376 +++++++++ shared/contracts/plugins/v1/README.md | 56 +- .../v1/fixtures/vcs-provider.plugin.json | 43 ++ .../plugins/v1/plugin-manifest.schema.json | 77 +- shared/contracts/rust-core-api.md | 43 +- shared/contracts/vcs-provider-v1.md | 446 +++++++---- shared/fixtures/vcs/model-v1.json | 624 +++++++++++++-- .../vcs-subversion-and-plugin-providers.json | 14 +- 27 files changed, 4512 insertions(+), 281 deletions(-) create mode 100644 rust/lithe-core/src/lsp/interface/vcs_provider.rs create mode 100644 rust/lithe-core/src/tests/vcs.rs create mode 100644 rust/lithe-core/src/vcs/commands.rs create mode 100644 rust/lithe-core/src/vcs/manifest.rs create mode 100644 rust/lithe-core/src/vcs/mod.rs create mode 100644 rust/lithe-core/src/vcs/model.rs create mode 100644 rust/lithe-core/src/vcs/requests.rs create mode 100644 rust/lithe-core/src/vcs/roots.rs create mode 100644 shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json diff --git a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md index 874a938e90..22e9e85898 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +++ b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md @@ -32,6 +32,7 @@ | `ui` | `ui`;版本 `2` 增加工具窗口里的网页内容 | | `buildSystems` | 声明式构建识别、模型和命令计划 | | `buildProviders` | 任一 `buildSystems[].provider`,包括自有进程和共享语言服务器两种写法 | +| `vcsProviders` | `vcsProviders[]`:插件进程实现的版本控制系统(工作副本标记、元数据路径、命令行工具) | 规则: @@ -46,6 +47,8 @@ 为什么 `serverProcess` 单独作为一个能力:启动字段由语言服务器和构建系统的自有 Provider 共用。以后新增一种启动方式时,只升级 `serverProcess`,`languageServers` 和 `buildSystems` 的其它字段不受牵连。共享写法的 Provider 复用插件里的语言服务器,所以不单独计入 `serverProcess`。两种 Provider 都必须声明 `buildProviders: 1`:能启动语言服务器或执行构建命令,并不代表能完成构建协议协商、请求和生命周期管理。纯模板构建插件不声明 `buildProviders`,否则会因多列能力被拒绝。 +`vcsProviders` 同样依赖 `serverProcess`:每个 VCS Provider 都是插件自己的进程,启动字段按 `serverProcess` 校验,所以清单要同时写 `vcsProviders: 1` 和 `serverProcess: 1`。只写前者,Core 会报少列了 `serverProcess`。工作副本标记和元数据路径留在 `vcsProviders` 里,因为宿主在进程启动前就要靠它们找到工作副本根,见[统一 VCS 模型提案](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)。 + ### 2. 宿主自己声明能运行什么 Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.declarative.validate`、`plugins.marketplace.validateIndex` 和 `plugins.marketplace.installReview` 里发送 `hostCapabilities: {"<能力>": [版本…]}`,由 Core 判断: @@ -56,7 +59,7 @@ Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.de 能力表放在宿主代码里,写在能力实现的旁边,Core 不再按平台去猜。原来按 `darwin-` 前缀和级别写死的判断,就是 Core 替宿主猜、又容易和宿主实现漂移的例子。 -macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 +macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。`vcsProviders: 1` 也是这样:Core 已经能校验清单、识别工作副本根并转发请求,但 macOS 和 Windows 都还没有接入 VCS Provider 会话,所以两端的能力表都不包含它,需要它的插件在两端都不可安装。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 ### 3. 市场索引带上能力版本 @@ -69,7 +72,7 @@ macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]` 清单回答的问题只有三个:启动什么进程、需要哪些权限、对哪些文件和工程生效。进程启动以后能回答哪些请求、支持哪些可选数据,在 LSP `initialize` 里协商: -- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。 +- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。VCS Provider 用同样的方式协商 `litheVcs`,回复里还带上它实现了哪些 VCS 能力(例如 `history`、`annotate`),宿主据此决定显示哪些操作,见 `shared/contracts/vcs-provider-v1.md`。 - Provider 从中选一个版本,回复 `{"version": 1}`。没有回复版本、或回复了宿主没提供的版本时,宿主不把它当作 Provider 使用。 - 双方都忽略协商对象、请求参数和结果里自己不认识的字段,所以同一个版本内可以继续增加可选数据。另一方不能忽略的改动,才需要新的协议版本。 diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index f21db215d2..bf907e0b8a 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -6,9 +6,11 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提交面板、项目树文件颜色、编辑器行标记(gutter)都直接使用 Git 类型。[Issue #827](https://github.com/1lck/Lithe-IDEA/issues/827) 要求支持 SVN,以后还可能有 Mercurial、Perforce 等其他 VCS(Version Control System,版本控制系统)。 -提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。Git 是内置 Provider,只在 Core 里加一层转换,现有 `git.*` 代码和契约不动;SVN 作为官方插件,以插件进程的形式接入,后续其他 VCS 走同一条路。 +提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。SVN 作为第一个官方插件 Provider,后端用官方 Node SDK 编写,以插件进程的形式接入,后续其他 VCS 走同一条路。 -对日常开发的影响:在统一层落地之前,新的 Git 功能照常写在 Git 模块里;但凡是“任何 VCS 都会有”的界面(文件状态颜色、gutter、提交、回滚、文件历史、Annotate),不要再新增对 Git 类型的直接依赖,等统一层就绪后改读 `vcs.*`。 +2026-10-22 评审决定:**Git 暂不接入统一层**。现有 Git 代码约 8 万行(Rust、Swift、TypeScript 合计,不含测试),两端还有重复的确定性逻辑,直接“包一层”会把这些债一起带进统一层。所以第一期统一层只服务插件 Provider,Git 保持现有 `git.*` 和 Git 模块不变;Git 的精简、迁移和改成内置官方插件另起提案(第 10 节)。 + +对日常开发的影响:新的 Git 功能照常写在 Git 模块里;但凡是“任何 VCS 都会有”的界面(文件状态颜色、gutter、提交、回滚、文件历史、Annotate),不要再新增对 Git 类型的直接依赖,以免 Git 迁移时多一处要拆。 ## 问题 @@ -34,11 +36,11 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提 | 层 | 位置(计划) | 负责 | 不负责 | | --- | --- | --- | --- | -| Rust Core 的 `vcs` 模块 | `rust/lithe-core/src/vcs/`(新) | 统一模型、`vcs.*` 命令、Provider 注册表(Registry,按 VCS ID 登记和查找实现的目录)、根目录识别、插件回复的解码和校验 | 启动进程、弹窗、存储 | -| 宿主 | macOS 新的 VCS 工作区模块;Windows 的 VCS 状态 store | 根映射与用户覆盖、文件监听触发刷新、Changelist、Shelve、凭据交互、Provider 进程生命周期、项目控制台 | 解析任何 VCS 的输出 | -| Provider | Git:Core 内置;其他 VCS:插件进程 | 回答状态、基线内容、历史、Annotate、提交、回滚、更新等事实 | 界面和跨根聚合 | +| Rust Core 的 `vcs` 模块 | `rust/lithe-core/src/vcs/`(新) | 统一模型、`vcs.*` 命令、根目录识别、请求计划、插件回复的解码和校验 | 启动进程、弹窗、存储 | +| 宿主 | macOS 新的 VCS 工作区模块;Windows 的 VCS 状态 store | 根映射与用户覆盖、文件监听触发刷新、凭据交互、Provider 进程生命周期、项目控制台 | 解析任何 VCS 的输出 | +| Provider | 插件进程(第一期只有 SVN);Git 暂不接入 | 回答状态、基线内容、历史、Annotate、提交、回滚、更新等事实 | 界面和跨根聚合 | -内置 Git Provider 在 Core 内部直接调用现有 `git::*` 函数,再把结果转换成统一形状。`git.*` 命令保持原样,现有 Git 界面和老调用方不受影响。插件 Provider 的回复先由 Core 校验(路径必须在根内、排序稳定、数量和大小有上限),再转换成和 Git 完全相同的形状。所以宿主拿到的 `vcs.status` 结果不区分来自哪个 Provider。 +插件 Provider 的回复先由 Core 校验(路径必须在根内、排序稳定、数量和大小有上限),再转换成统一形状。所以宿主拿到的结果不区分来自哪个 Provider,以后 Git 接入时也只需要产出同一形状。 ### 2. 统一模型只收所有 VCS 都成立的概念 @@ -58,7 +60,7 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提 ### 3. 界面看能力,不看 VCS 的名字 -每个 Provider 声明自己支持哪些能力,界面只按能力出现入口。必备能力是状态、基线内容、提交和回滚;其余都可选:历史、文件历史、Annotate、更新、推送、暂存区(staging)、分支、冲突处理、文件操作跟踪、锁、属性、清理等。 +每个 Provider 声明自己支持哪些能力,界面只按能力出现入口。必备能力是状态、基线内容、提交和回滚;协议版本 1 的可选能力是历史、文件历史、Annotate、更新、推送、暂存区(staging)、文件操作跟踪、冲突处理和清理。分支、锁、属性、忽略规则等到有第二个 Provider 需要时,再按能力版本加入。 集中式与分布式 VCS 的差别也表达成能力: @@ -90,31 +92,50 @@ Git Log 图、Rebase、Worktree、Stash、Push 和 Fetch 对话框、历史改 - 进程启动后,宿主在 `initialize` 里通过 `capabilities.experimental.litheVcs` 协商版本,Provider 在回复里给出自己的能力集。能力集描述的是运行中的进程,所以不写进清单,这遵守[声明式插件按能力分版本](../../implemented/architecture/2026-10-17-plugin-capability-versions.md)第 4 节的规则。 - 请求方法统一以 `lithe/vcs/` 开头。宿主不按 Provider ID 写分支;进程按“插件 ID + VCS ID + 工作区”隔离,生命周期规则与构建 Provider 相同。 -字段和方法的草案见 [VCS Provider 契约草案](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。草案在 P1 落地前都可以修改,任何 Core 命令、清单校验和宿主能力表都还不接受这些名字。 +字段和方法见 [VCS Provider 契约](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。Core 已按契约实现(见第 9 节的进度),但还没有宿主声明 `vcsProviders`,所以插件市场不会列出这类插件;宿主接入前契约仍可以修改,改动要同时更新 fixtures 和 Core 测试。 ### 7. SVN 作为官方插件 SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 输出。IDEA 的 svn4idea 也是这样做的(`plugins/svn4idea/src/org/jetbrains/idea/svn/status/CmdStatusClient.kt` 使用 `svn status --xml`)。这和 Lithe 使用系统 Git 的方式一致,不需要打包 JVM 或跨平台编译 libsvn。 -凭据规则沿用[Git 执行与项目控制台](../../implemented/architecture/2026-09-12-git-execution-and-project-console.md):SVN 自己的认证缓存优先回答;需要交互时,Provider 通过协议向宿主发出认证挑战,宿主复用现有认证界面。命令始终带 `--non-interactive`,密码通过 `--password-from-stdin` 传入,不出现在命令参数、控制台和日志里,Lithe 也不保存密码。 +后端进程用官方 Node SDK([lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) 的 `sdk/node`)编写,以 `runtime` 方式启动(`tool: node`)。SDK 已经负责线协议、协商、取消、子进程清理和错误映射,Cargo 社区插件也走这条路;同一份后端在 macOS 和 Windows 上都能运行,插件包里没有原生二进制,不涉及按架构编译和 Gatekeeper。代价是用户除了 `svn` 还需要 Node 20.3 以上,安装审查和缺少运行时的提示要说清楚。SDK 新增 `litheVcs` 支持,和 `litheBuild` 并列;2026-10-23 起 SDK 不在主仓库,这部分改动在 lithe-plugin-docs 提交(见[插件契约、SDK 与开发者资料的发布](../../implemented/architecture/2026-10-23-plugin-contract-publishing.md))。 + +凭据规则沿用[Git 执行与项目控制台](../../implemented/architecture/2026-09-12-git-execution-and-project-console.md):命令始终带 `--non-interactive`,SVN 自己的认证缓存优先回答。第一版只依赖这份缓存,认证失败时返回 `vcs.authentication.failed`,宿主提示原因;交互式认证(宿主弹窗收集用户名和密码,经 `--password-from-stdin` 传给 `svn`)在宿主阶段实现。密码不出现在命令参数、控制台和日志里,Lithe 也不保存密码。 ### 8. 宿主接手与 VCS 无关的状态 -- **根映射**:一个工作区可以同时有 Git 和 SVN 根。文件归属最深的那个根;同一目录同时出现两种标记时,要求用户在设置里选择,不能猜。IDEA 用 `VcsDirectoryMapping` 保存这种映射。 -- **Changelist 和 Shelve**:从 `LitheGitModule` 上移到宿主的 VCS 层,存储键从“Git 仓库路径”改为“VCS 根 ID”,迁移时继续读取旧格式。IDEA 的 `ChangeListManagerImpl` 同样在 `platform/vcs-impl`,不属于任何一种 VCS。 +- **根映射**:一个工作区可以同时有 Git 和 SVN 根。文件归属最深的那个根;同一目录同时出现两种标记时,要求用户在设置里选择,不能猜。IDEA 用 `VcsDirectoryMapping` 保存这种映射。Git 暂不接入统一层,但 Core 识别根时仍认得 `.git` 标记,这样嵌套和歧义的判断对 Git 也成立,Git 根继续交给 Git 模块。 +- **Changelist 和 Shelve**:长期应从 `LitheGitModule` 上移到宿主的 VCS 层(IDEA 的 `ChangeListManagerImpl` 在 `platform/vcs-impl`,不属于任何一种 VCS)。这一步和 Git 迁移一起做;第一期 SVN 根的变更不分组,也不支持 Shelve。 - **监听刷新**:[单仓库 Git 状态监听提案](../bug-fix/2026-09-13-single-repository-git-status-observation.md)里要求的监听范围,扩展成“每个 Provider 声明的元数据目录”,例如 SVN 的 `.svn/wc.db`。 -- **项目控制台**:现有 Git 执行事件多带一个 VCS ID 字段,插件 Provider 用通知上报实际执行的命令和输出。展示、折叠和脱敏规则不变。 +- **项目控制台**:现有 Git 执行事件多带一个 VCS ID 字段。插件 Provider 用 `lithe/vcs/execution` 通知上报实际执行的命令和输出,Core 把它转成会话日志事件交给宿主。展示、折叠和脱敏规则不变。 ### 9. 分阶段落地 +2026-10-22 起按“先底层、再 SVN、Git 最后”的顺序: + | 阶段 | 内容 | 完成标志 | | --- | --- | --- | | P0 | 本提案、契约草案和 fixtures | 评审通过 | -| P1 | Core `vcs` 模块:类型、注册表、根识别;内置 Git Provider 的读路径(状态、基线内容、历史、文件历史、Annotate) | 同一批测试仓库上 `vcs.*` 与 `git.*` 的结果等价 | -| P2 | macOS 只读的通用界面改读 `vcs.*`:项目树颜色、gutter、状态栏 | Git 项目的行为和外观没有变化 | -| P3 | 插件 `vcsProviders` 能力、`litheVcs` 协商、宿主 Provider 会话(复用构建 Provider 的会话代码);Changelist/Shelve 上移;通用提交面板 | 测试用的假 Provider 插件跑通完整流程 | -| P4 | SVN 官方插件 MVP | 见验收标准 | -| P5 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | +| P1 | Core:插件清单 `vcsProviders` 能力、根识别、请求计划与插件回复解码、`litheVcs` 协商和 `lsp.vcsRequest`;Node SDK 支持 `litheVcs` | fixtures 和用 SDK 写的假 Provider 经真实 Core 会话跑通 | +| P2 | SVN 官方插件(Node SDK 后端) | 录制的 `svn --xml` 样例解析测试;本地 `file://` 仓库上的端到端测试 | +| P3 | macOS 宿主:Provider 会话(复用构建 Provider 的会话代码)、项目树颜色和 gutter 合并展示插件根、通用“版本控制”工具窗口(变更、提交、回滚、更新)、文件操作跟踪、交互式认证 | 见验收标准 | +| P4 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | +| 之后 | Git 精简并接入统一层,见第 10 节 | 另起提案 | + +P1 已完成(2026-10-22): + +- 插件清单:API 级别 3 的 `vcsProviders`、`process.vcs` 权限和 `onVcsRoot:` 激活事件;`git` 是保留 ID。安装审查列出 Provider 进程,SVN 这类工具路径另放在 `vcsTool` 字段,不覆盖运行时的 `tool`。 +- 根识别 `vcs.detectRoots`:只读目录项,不读文件、不启动进程;工作区自己没有标记时向上查祖先目录,所以“工作区开在 SVN 工作副本的子目录里”也能识别。新增 `vcs.ownerOf`,让各宿主用同一条“最深根”规则给文件找归属,不必各写一份。 +- 会话:`litheVcs` 只提供给插件启动的会话;`lsp.vcsRequest` 按操作名生成 `lithe/vcs/*` 请求,未协商的能力在写出前就拒绝;Provider 只能使用契约列出的错误码,其余一律变成 `vcs.provider.failed`。`lithe/vcs/authenticate` 在宿主实现交互式认证前按未知请求回绝。 +- Node SDK:`backend.vcs(id, handlers, traits)` 按声明的处理函数协商能力,VCS 处理函数里的 `context.run` 会自动发出 `lithe/vcs/execution` 执行报告(只带命令行、退出码、耗时和截断后的 stderr,不带 stdout)。示例 `sdk/node/examples/sample-vcs` 是一个把修订存在 JSON 文件里的玩具 VCS,端到端场景经真实 Core 会话跑通它的协商和全部 11 种操作。SDK、示例和端到端场景随 SDK 迁到 lithe-plugin-docs,在那里用 `scripts/sdk-e2e.mjs --ref <本分支>` 运行;主仓库只保留契约、fixtures 和 Core 测试。 + +### 10. Git 什么时候、怎样接入 + +Git 接入不在本提案的范围内,这里只记下评审时已经确认的方向,供以后的提案使用: + +- **先精简再接入。** 提交图投影在 Core 里已有一份(`git/graph.rs` 的 `project_graph`),但生产代码没有调用,macOS 和 Windows 又各写了一份布局;执行日志、Changelist、状态模型等确定性逻辑也是两端各一份。接入时按切片迁移,每一片两端同时切换,并在同一个 PR 里删掉旧路径。 +- **Git 改成随应用内置的官方插件**,和 SVN 走同一个注册入口;通用界面不再直接依赖 `LitheGitModule`。是否搬到独立进程是传输方式问题,可以单独决定。 +- **平台层功能先做在统一层**:三方合并工具、行级历史、Changelist、Shelf 在 IDEA 里都属于平台而不属于 git4idea,补齐时直接给所有 VCS 用。 ## 考虑过的备选方案 @@ -126,9 +147,17 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 界面只需要面对一套 API,看起来最“统一”。但 Rebase、Worktree、暂存区这些概念在 SVN 里不存在,要么让通用层变成 Git 的翻版,要么为了照顾最弱的 VCS 削掉 Git 的能力。IDEA 也没有这样做,因此只统一跨 VCS 成立的概念。 +### 第一期就把 Git 包成内置 Provider + +最初的方案是在 Core 里给现有 `git::*` 加一层转换,让 Git 和插件同时产出统一形状。评审时发现这样会把两端重复的逻辑和 Git 专属的结果类型原样带进统一层,以后再精简反而更难,因此改为第一期不接 Git,先用 SVN 把底层跑通(第 10 节)。 + ### Git 也改成插件进程 -这样内置和插件完全对称。但 Git 是 Lithe 的核心体验,状态读取在热路径上,认证、取消和执行事件链路也刚稳定;搬到进程外对用户没有收益,迁移风险却很大。内置 Provider 和插件 Provider 共用 Core 里同一套转换和校验,结果形状已经对称,因此不搬。 +这样内置和插件完全对称。但 Git 是 Lithe 的核心体验,状态读取在热路径上,认证、取消和执行事件链路也刚稳定;第一期不动 Git。以后 Git 改成内置官方插件时,进程内还是进程外只是传输方式,可以单独决定。 + +### SVN 后端用 Rust 写成原生可执行文件 + +不需要用户安装 Node,启动也更快。但插件包要为每个平台和架构各编译一份、单独签名,macOS 上下载来的原生可执行文件还要处理 Gatekeeper 隔离;而 Node SDK 已经实现协议、取消和子进程清理,社区构建插件也在用。SVN 后端的工作主要是调用 `svn` 和解析 XML,不是性能热点,因此选 Node SDK。 ### SVN 内置到宿主 @@ -144,23 +173,25 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` ## 验收标准 -- P1:在同一批 Git 测试仓库上,`vcs.status`、基线内容、历史、文件历史和 Annotate 与对应 `git.*` 结果等价;大仓库上 `vcs.status` 比 `git.status` 只多出转换开销,不额外启动进程。 -- P2:macOS 项目树颜色、gutter 和状态栏改读统一模型后,Git 项目的现有测试和截图检查全部通过,行为没有变化。 -- P3:一个只用于测试的假 VCS Provider 插件完成安装审查、协商、状态、提交、回滚和进程清理;停用或卸载插件后进程退出,它的根从界面消失;未协商成功的进程收不到 `lithe/vcs/*` 请求。 -- P4:SVN 官方插件在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete 跟踪、Annotate、文件历史和冲突显示;密码不出现在进程参数、控制台和日志中。 +- P1:清单校验接受和拒绝的情况都有 fixture;根识别覆盖嵌套、歧义和 Git worktree 文件标记;插件回复的校验用例全部通过;用 Node SDK 写的假 Provider 经真实 Core 会话完成协商和每种请求,未协商成功的进程收不到 `lithe/vcs/*` 请求。 +- P2:SVN 插件对录制的 `svn status/info/log/blame --xml` 样例解析正确;在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete、Annotate、文件历史和冲突显示。 +- P3:macOS 上安装 SVN 插件后完成安装审查、根识别、文件颜色、gutter、提交、回滚、更新和进程清理;停用或卸载插件后进程退出,它的根从界面消失;Git 项目的行为没有变化;密码不出现在进程参数、控制台和日志中。 - 每个阶段都运行对应的 Core、契约、Note 和平台验证脚本;Windows 的结论只来自 Windows 上的实际运行。 ## 风险 -- **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。缓解办法:统一层只包装稳定的概念,`git.*` 不冻结,Git 专属功能继续在 Git 模块里演进。 -- **状态读取是热路径。** 内置 Git Provider 在 Core 内直接转换,不额外起进程。SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 -- **Changelist 和 Shelve 的存储迁移。** 改变存储键可能丢失用户已有分组。迁移时保留读取旧格式,并用旧数据做回归测试。 +- **统一层只被一种 VCS 验证过。** 第一期只有 SVN 用统一层,模型里可能藏着 SVN 的偏见。缓解办法:模型只收第 2 节表里 Git 和 SVN 都成立的概念,Git 专属的暂存维度已经作为可选能力进入 fixtures;Git 接入时如果发现缺口,按能力版本加字段。 +- **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。第一期不碰 Git,`git.*` 不冻结。 +- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 - **混合根和嵌套根。** Git 仓库里嵌套 SVN 工作副本时,文件必须只归属一个根,否则会重复显示或重复提交。识别规则和用户映射都要有 fixture 覆盖。 - **插件进程常驻的成本。** 只在识别到对应根时启动,关闭工作区即回收,规则与构建 Provider 相同。 -- **回退方式。** 每个阶段都只新增路径。P2 切换后保留旧读取路径一个版本,出问题可以切回。 +- **回退方式。** 每个阶段都只新增路径,停用 SVN 插件即回到现状。 ## 适用范围 +- `rust/lithe-core/src/vcs/` +- `rust/lithe-core/src/plugins/` +- `rust/lithe-core/src/lsp/interface/` - `rust/lithe-core/src/git/` - `rust/lithe-git-host/` - `macos/Sources/LitheGitModule/` diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index a29c869700..fa8b0118ae 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod process; mod project; mod protocol; mod runtime; +mod vcs; pub use protocol::{ CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 348be598d6..eff141681c 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -593,6 +593,30 @@ pub(super) fn client_apply_server_message_for_plugin( .server_capabilities .retain(|feature| feature != plugin_function::FUNCTIONS_FEATURE); } + // Only a plugin's own process may act as a VCS provider; a + // host-owned server that happens to answer is ignored. + state.vcs_provider = plugin_owned + .then(|| { + super::vcs_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + ) + }) + .flatten(); + match &state.vcs_provider { + Some(negotiation) => events.push(LspClientEvent { + kind: super::vcs_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!(negotiation)), + error: None, + }), + None => state + .server_capabilities + .retain(|feature| feature != super::vcs_provider::VCS_PROVIDER_FEATURE), + } state.text_document_sync = text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); if let Some(options) = result @@ -619,6 +643,7 @@ pub(super) fn client_apply_server_message_for_plugin( state.diagnostics.clear(); state.diagnostic_versions.clear(); state.plugin_functions.clear(); + state.vcs_provider = None; responses.push(json_rpc_message_without_params(None, "exit")?); } let result = if pending.as_deref() == Some("textDocument/semanticTokens/full") { @@ -724,6 +749,8 @@ fn experimental_capabilities(plugin_owned: bool) -> Value { if plugin_owned { let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; + let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); + experimental[vcs_key] = vcs_offer; } experimental } @@ -1671,6 +1698,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } + if super::vcs_provider::negotiate(capabilities).is_some() { + insert_unique(&mut values, super::vcs_provider::VCS_PROVIDER_FEATURE); + } values } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 32eb50caa4..4f891153ad 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -59,6 +59,10 @@ const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; /// large build, so it may outlive the ordinary request timeout, but never /// stays pending without a deadline. const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Upper bound of one VCS-provider request. An update or check-in of a large +/// working copy over a slow link can take long, but never stays pending +/// without a deadline. +const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; const MONITOR_INTERVAL_MS: u64 = 10; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -390,6 +394,37 @@ pub struct BuildProviderRequest { pub timeout_milliseconds: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One VCS-provider request. Core plans the method and params from the +/// operation and its arguments, so the host never writes a raw method. The +/// result arrives as a `requestCompleted` event whose `result.value` is the +/// provider's reply, which the host passes to `vcs.decodeReply`; a failure +/// carries a `vcs.*` code. +pub struct VcsProviderRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + /// The VCS the host launched the session for; a session that negotiated + /// another VCS is refused. + pub vcs: String, + /// Operation name, such as `status` or `checkin`. + pub operation: String, + /// Native absolute path of the working-copy root. + pub root: String, + /// Absolute path of the VCS client the host resolved, when the provider + /// declares one. + #[serde(default)] + pub tool: Option, + /// Operation arguments; see `crate::vcs` for each operation's shape. + #[serde(default)] + pub arguments: Value, + /// Deadline of this request, in milliseconds, capped at + /// `MAX_VCS_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. + #[serde(default)] + pub timeout_milliseconds: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One plugin-function invocation. The result arrives as a `requestCompleted` @@ -1222,6 +1257,18 @@ pub fn build_provider_request( Ok(OperationResponse { operation_id }) } +/// Sends one VCS-provider request to a session that negotiated the protocol. +pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .vcs_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + /// Invokes one plugin function on a session of the function's plugin. pub fn plugin_function_request( request: PluginFunctionRequest, @@ -2021,6 +2068,84 @@ impl RuntimeSession { self.send_request_or_fail(&outbound_order, messages, &request_id) } + /// Writes a VCS-provider request planned by `crate::vcs::plan`, which + /// checks the operation's arguments and negotiated capability. + fn vcs_request( + &self, + request: VcsProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let operation = crate::vcs::Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) + || state.java_builds.contains(&operation_id) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let negotiation = state + .client + .vcs_provider + .as_ref() + .filter(|negotiation| negotiation.vcs == request.vcs) + .ok_or_else(|| { + CoreError::new( + ErrorCode::NotSupported, + format!("The process is not a negotiated {} provider.", request.vcs), + ) + .with_details("vcs.provider.unavailable") + })?; + let planned = crate::vcs::plan( + operation, + &request.root, + request.tool.as_deref(), + request.arguments, + &negotiation.capabilities, + )?; + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_VCS_PROVIDER_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = + allocate_raw_request(state.client.clone(), planned.method, planned.params)?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: planned.method.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + /// Writes a `lithe/function/invoke` request. The plan comes from /// `plugin_function::plan_invocation`, so plugin identity, negotiation, /// and registration are checked before anything reaches the process. @@ -2904,6 +3029,40 @@ impl RuntimeSession { ); } } + Some(PendingKind::Feature) + if pending_before + .as_ref() + .is_some_and(|pending| crate::vcs::is_vcs_method(&pending.method)) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = match super::vcs_provider::reply_outcome(&value) { + Ok(result) => (Some(result), None), + Err(failure) => ( + None, + Some(runtime_error( + self, + failure.code, + "request", + Some(&pending.method), + None, + &failure.message, + None, + None, + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } Some(PendingKind::Feature) => { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { @@ -3526,6 +3685,8 @@ impl RuntimeSession { push_functions_event(self, &mut state, result); } else if event.kind == "functionProgress" { push_function_progress_locked(self, &mut state, event.result); + } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { + push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); } else if event.kind == "diagnostics" { if let Some(uri) = event.uri.as_deref() { push_diagnostics_event( @@ -5276,6 +5437,37 @@ fn push_function_progress_locked( ); } +/// Publishes the VCS and capabilities a provider negotiated, so the host can +/// check the VCS against the manifest entry and decide which actions to show. +fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "vcsProvider".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Publishes the session's registered functions after a registry change. fn push_functions_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { let sequence = take_sequence(state); @@ -5860,6 +6052,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.request_by_operation.clear(); state.function_by_operation.clear(); clear_function_registry(session, state); + state.client.vcs_provider = None; state.linked_document_paths = LinkedDocumentPaths::default(); state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); @@ -10826,6 +11019,260 @@ public class Main { assert!(harness.server.await_notification("$/cancelRequest")); } + // VCS providers against a scripted backend. Only a plugin-owned session + // is offered `litheVcs`, and only one whose process answered with a VCS + // ID and every required capability receives requests, each planned by + // Core from an operation name. + + const VCS_PLUGIN: &str = "dev.lithe.fixture.vcs-provider"; + + fn vcs_capabilities() -> Value { + json!({ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 }) + } + + fn vcs_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(VCS_PLUGIN.to_string()); + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheVcs": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn negotiated_vcs_provider() -> Harness { + vcs_backend(json!({ "version": 1, "vcs": "svn", "capabilities": vcs_capabilities() })) + } + + /// A native absolute path on every platform the tests run on. + fn working_copy() -> String { + std::env::temp_dir() + .join("lithe-vcs-working-copy") + .to_string_lossy() + .into_owned() + } + + fn vcs_request( + harness: &Harness, + vcs: &str, + operation: &str, + arguments: Value, + ) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .vcs_request( + VcsProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + vcs: vcs.to_string(), + operation: operation.to_string(), + root: working_copy(), + tool: None, + arguments, + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) + } + + /// Feature names the session recorded from the provider's answer. + fn vcs_features(harness: &Harness) -> Vec { + let session = harness.session(); + let state = session.lock_state().expect("state"); + state.client.server_capabilities.clone() + } + + fn no_vcs_request_written(harness: &Harness) -> bool { + harness.server.messages().iter().all(|message| { + !message["method"] + .as_str() + .is_some_and(|method| method.starts_with("lithe/vcs/")) + }) + } + + #[test] + fn host_sessions_are_not_offered_the_vcs_protocol() { + let harness = Harness::ready(); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheVcs") + .is_none()); + let error = vcs_request(&harness, "svn", "status", json!({})) + .expect_err("a host session is never a provider"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + /// The negotiated VCS and capabilities reach the host as one event, so + /// it can check the VCS against the manifest and pick the actions to show. + #[test] + fn a_negotiated_provider_is_announced_with_its_capabilities() { + let mut harness = negotiated_vcs_provider(); + let event = harness + .await_event(|event| event.kind == "vcsProvider") + .clone(); + assert_eq!( + event.result, + Some(json!({ "vcs": "svn", "capabilities": vcs_capabilities() })) + ); + assert!(vcs_features(&harness) + .iter() + .any(|feature| feature == "litheVcs")); + } + + /// Version 1 has no host side for credentials or console routing yet: an + /// authentication request is refused so the provider fails with + /// `vcs.authentication.failed`, and an execution report is passed on as a + /// session log entry. + #[test] + fn provider_authentication_is_refused_and_execution_reports_pass_through() { + let mut harness = negotiated_vcs_provider(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "auth-1", + "method": "lithe/vcs/authenticate", + "params": { "realm": " Example" } + })); + assert_eq!(await_reply(&harness, "auth-1")["error"]["code"], -32601); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "lithe/vcs/execution", + "params": { "kind": "started", "arguments": ["status", "--xml"] } + })); + let event = harness + .await_event(|event| { + event.kind == "log" && event.message.as_deref() == Some("lithe/vcs/execution") + }) + .clone(); + assert!(event + .detail + .as_deref() + .is_some_and(|detail| detail.contains("--xml"))); + } + + /// An answer without a required capability makes the process a plain + /// plugin session: no announcement, no feature, and no request. + #[test] + fn an_incomplete_vcs_answer_is_not_a_provider() { + let mut harness = vcs_backend(json!({ + "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1 } + })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "vcsProvider")); + assert!(vcs_features(&harness) + .iter() + .all(|feature| feature != "litheVcs")); + let error = vcs_request(&harness, "svn", "status", json!({})).expect_err("refused"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + #[test] + fn a_vcs_request_is_planned_from_its_operation_and_returns_the_reply() { + let mut harness = negotiated_vcs_provider(); + let operation = vcs_request( + &harness, + "svn", + "checkin", + json!({ "paths": ["src/b.txt", "src/a.txt"], "message": "Fix" }), + ) + .expect("accepted"); + let request_id = harness + .server + .await_request("lithe/vcs/checkin") + .expect("the request should reach the provider"); + assert_eq!( + harness.written_request(&request_id)["params"], + json!({ + "paths": ["src/a.txt", "src/b.txt"], + "message": "Fix", + "root": { "path": working_copy() }, + "tool": null + }) + ); + let reply = json!({ "revision": { "id": "12", "display": "r12", "kind": "number" } }); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); + let completed = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(completed.error.is_none(), "{:?}", completed.error); + assert_eq!(completed.result, Some(json!({ "value": reply }))); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + + /// The host names an operation, never a method, so the session cannot + /// be used as a tunnel; another VCS, an unknown operation, a capability + /// the provider lacks, or a path leaving the root never reach the wire. + #[test] + fn vcs_requests_are_refused_before_they_are_written() { + let harness = negotiated_vcs_provider(); + let error = vcs_request(&harness, "hg", "status", json!({})).expect_err("wrong VCS"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + let error = + vcs_request(&harness, "svn", "shutdown", json!({})).expect_err("no such operation"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + let error = vcs_request(&harness, "svn", "annotate", json!({ "path": "a.txt" })) + .expect_err("not negotiated"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + let error = vcs_request(&harness, "svn", "rollback", json!({ "paths": ["../x"] })) + .expect_err("outside the root"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert!(no_vcs_request_written(&harness)); + } + + /// A provider may name one of the contract's failure codes; any other + /// code, including one of Lithe's own, becomes the generic failure. + #[test] + fn vcs_failures_keep_only_contract_codes() { + let mut harness = negotiated_vcs_provider(); + for (index, (data, expected)) in [ + (json!({ "code": "vcs.outOfDate" }), "vcs.outOfDate"), + ( + json!({ "code": "plugin.vcs.invalidOutput" }), + "vcs.provider.failed", + ), + ] + .into_iter() + .enumerate() + { + let operation = vcs_request(&harness, "svn", "update", json!({})).expect("sent"); + let request_id = harness + .server + .await_request_at("lithe/vcs/update", index) + .expect("the request should reach the provider"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32000, "message": "Working copy is out of date.", "data": data } + })); + let error = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .error + .clone() + .expect("an error reply fails the request"); + assert_eq!(error.code, expected); + assert_eq!(error.message, "Working copy is out of date."); + } + } + // Plugin functions against a scripted backend. The scripted server plays // the plugin process, so every outcome (result, failure, cancellation, // deadline, late reply, revocation, and compatibility with a backend that @@ -10992,7 +11439,11 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"], - json!({ "litheBuild": { "versions": [1] }, "litheFunctions": { "versions": [1] } }) + json!({ + "litheBuild": { "versions": [1] }, + "litheFunctions": { "versions": [1] }, + "litheVcs": { "versions": [1] } + }) ); // Another plugin's reference never reaches this process. @@ -11234,12 +11685,13 @@ public class Main { revoked.error.as_ref().map(|error| error.code.as_str()), Some("plugin.function.revoked") ); - assert!(harness + // The revocation is published before the cancellation is written, so + // the write is awaited rather than expected to be there already. + let cancel = harness .server - .messages() - .iter() - .any(|message| message["method"] == "$/cancelRequest" - && message["params"]["id"] == request_id)); + .await_notification_at("$/cancelRequest", 0) + .expect("the revoked call should be cancelled on the wire"); + assert_eq!(cancel["params"]["id"], request_id); let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) .expect_err("a withdrawn function is unknown"); assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 7fe3b90fab..7214d5a168 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -9,6 +9,7 @@ mod process; mod scripted; mod transport; mod types; +mod vcs_provider; pub(crate) use client::*; pub(crate) use engine::*; diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 95f5083d6f..5f1cd4e3dd 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -139,6 +139,10 @@ pub struct LspClientState { /// negotiated; empty otherwise. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub plugin_functions: BTreeSet, + /// The VCS and capabilities a plugin process answered under + /// `litheVcs`; `None` when the protocol was not negotiated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_provider: Option, } impl Default for LspClientState { @@ -156,6 +160,7 @@ impl Default for LspClientState { diagnostics: BTreeMap::new(), diagnostic_versions: BTreeMap::new(), plugin_functions: BTreeSet::new(), + vcs_provider: None, } } } diff --git a/rust/lithe-core/src/lsp/interface/vcs_provider.rs b/rust/lithe-core/src/lsp/interface/vcs_provider.rs new file mode 100644 index 0000000000..23a6f6266c --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/vcs_provider.rs @@ -0,0 +1,183 @@ +//! The VCS-provider protocol a plugin's LSP base-protocol process speaks. +//! +//! The manifest says how to start the process and which VCS it provides; +//! the protocol version, the VCS ID, and the provider's capabilities are +//! negotiated in `initialize` under `capabilities.experimental.litheVcs`. +//! Only plugin-owned sessions are offered the protocol, and requests are +//! written only to a session whose answer named a version this client +//! offered, a valid VCS ID, and every required capability. The rules live in +//! `shared/contracts/vcs-provider-v1.md`. + +use crate::vcs::{negotiated_capabilities, Capabilities}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Key under `capabilities.experimental` on both sides of the exchange. +const EXPERIMENTAL_KEY: &str = "litheVcs"; + +/// VCS-provider protocol versions this client speaks, in preference order. +const SUPPORTED_VERSIONS: [u64; 1] = [1]; + +/// Feature name recorded in the session's server capabilities once a +/// provider answered a supported version. +pub(crate) const VCS_PROVIDER_FEATURE: &str = "litheVcs"; + +/// Client event that carries the negotiated provider to the engine. +pub(crate) const NEGOTIATED_EVENT: &str = "vcsProviderNegotiated"; + +/// Longest provider error message passed to the host, in UTF-8 bytes. +const MAX_MESSAGE_BYTES: usize = 4096; + +/// Contract codes a provider may put in `error.data.code`. Any other code, +/// or none, becomes [`codes::FAILED`]. +const PROVIDER_ERROR_CODES: &[&str] = &[ + "vcs.authentication.failed", + "vcs.conflict", + "vcs.outOfDate", + "vcs.tool.unavailable", + "vcs.workingCopy.locked", + "vcs.workingCopy.unsupported", +]; + +/// Error codes Core assigns to a failed VCS request. +pub(crate) mod codes { + /// The provider failed without one of the contract's specific codes. + pub const FAILED: &str = "vcs.provider.failed"; + /// The reply is not a JSON-RPC result. + pub const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; +} + +/// What a provider answered in `initialize`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VcsNegotiation { + /// The VCS the process provides; the host checks it against the + /// manifest entry it launched. + pub vcs: String, + /// Capabilities of protocol version 1 the provider supports. + pub capabilities: Capabilities, +} + +/// The client half of the negotiation, placed under `capabilities.experimental`. +pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { + (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) +} + +/// The provider's answer, or `None` when it chose no offered version, named +/// no valid VCS ID, or lacks a required capability. Unknown fields and +/// capabilities are ignored, as the contract requires. +pub(crate) fn negotiate(capabilities: &Value) -> Option { + let answer = capabilities + .get("experimental") + .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; + let version = answer.get("version").and_then(Value::as_u64)?; + if !SUPPORTED_VERSIONS.contains(&version) { + return None; + } + let vcs = answer + .get("vcs") + .and_then(Value::as_str) + .filter(|vcs| crate::vcs::is_vcs_id(vcs))?; + Some(VcsNegotiation { + vcs: vcs.to_string(), + capabilities: negotiated_capabilities(answer.get("capabilities")?)?, + }) +} + +/// A failed VCS request: a contract code and the provider's message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VcsFailure { + pub(crate) code: &'static str, + pub(crate) message: String, +} + +fn bounded_message(value: &str) -> String { + let mut end = value.len().min(MAX_MESSAGE_BYTES); + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +/// Normalizes the provider's JSON-RPC response. A success is wrapped as +/// `{ "value": result }` like build-provider replies, so the host passes +/// `value` to `vcs.decodeReply` unchanged. +pub(crate) fn reply_outcome(response: &Value) -> Result { + if let Some(error) = response.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .map(bounded_message) + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "The VCS provider failed.".to_string()); + let code = error + .get("data") + .and_then(|data| data.get("code")) + .and_then(Value::as_str) + .and_then(|code| PROVIDER_ERROR_CODES.iter().find(|known| **known == code)) + .copied() + .unwrap_or(codes::FAILED); + return Err(VcsFailure { code, message }); + } + match response.get("result") { + Some(result) => Ok(json!({ "value": result })), + None => Err(VcsFailure { + code: codes::INVALID_OUTPUT, + message: "The VCS provider returned no result.".to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn answer(value: Value) -> Value { + json!({ "experimental": { "litheVcs": value } }) + } + + const REQUIRED: &str = r#"{ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1 }"#; + + #[test] + fn only_a_complete_answer_to_an_offered_version_counts() { + let capabilities: Value = serde_json::from_str(REQUIRED).expect("JSON"); + let negotiation = negotiate(&answer(json!({ + "version": 1, "vcs": "svn", "capabilities": capabilities, "future": true + }))) + .expect("negotiated"); + assert_eq!(negotiation.vcs, "svn"); + assert_eq!(negotiation.capabilities.len(), 4); + // A version the host never offered, a malformed VCS ID, a missing + // capability map, or a missing required capability must keep VCS + // requests off the session. + for rejected in [ + json!({ "version": 2, "vcs": "svn", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "SVN", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "svn" }), + json!({ "version": 1, "vcs": "svn", "capabilities": { "status": 1 } }), + ] { + assert_eq!(negotiate(&answer(rejected.clone())), None, "{rejected}"); + } + assert_eq!(negotiate(&json!({})), None); + } + + #[test] + fn provider_errors_keep_only_contract_codes() { + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "Out of date", "data": { "code": "vcs.outOfDate" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, "vcs.outOfDate"); + // A plugin cannot make its own code look like one of Lithe's. + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "", "data": { "code": "plugin.vcs.invalidOutput" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, codes::FAILED); + assert_eq!(failure.message, "The VCS provider failed."); + assert_eq!( + reply_outcome(&json!({ "result": null })).expect("ok"), + json!({ "value": null }) + ); + } +} diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 69eaaf0d6b..17bce296ec 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -41,6 +41,9 @@ pub(crate) const UI: &str = "ui"; pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +/// `vcsProviders[]`: version control systems implemented by a plugin +/// process; see `crate::vcs`. +pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; /// Shared executable candidate order and strict explicit selection. pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; @@ -57,6 +60,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (SERVER_PROCESS, &[1]), (SETTINGS, &[1, 2]), (UI, &[1, 2]), + (VCS_PROVIDERS, &[1]), ]; /// Longest capability ID, so a hostile index cannot grow host state. diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index f302d6ac9a..322eb3f866 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -44,6 +44,9 @@ const PERMISSION_UI_WEB: &str = "ui.web"; /// Running a declared build system's executable: model, task, test, and /// dependency commands. API level 3. const PERMISSION_BUILD: &str = "process.build"; +/// Letting a VCS provider change the working copy and contact its server. +/// API level 3. +const PERMISSION_VCS: &str = "process.vcs"; const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_BUILD, PERMISSION_LANGUAGE_SERVER, @@ -51,6 +54,7 @@ const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_TEST, PERMISSION_UI, PERMISSION_UI_WEB, + PERMISSION_VCS, ]; /// Runtime tools the host knows how to discover and pass to a language /// server. The host exports each as `LITHE__PATH` and puts its @@ -156,6 +160,10 @@ pub struct DeclarativePluginManifest { /// shape and its validation live in `crate::build`. #[serde(default, skip_serializing_if = "Vec::is_empty")] build_systems: Vec, + /// Version control systems the plugin implements. API level 3; the + /// shape and its validation live in `crate::vcs`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + vcs_providers: Vec, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -416,6 +424,7 @@ pub(crate) struct ServerProcess { pub(crate) enum ServerRole { LanguageServer, BuildProvider, + VcsProvider, } impl ServerRole { @@ -423,6 +432,7 @@ impl ServerRole { match self { Self::LanguageServer => "language server", Self::BuildProvider => "build provider", + Self::VcsProvider => "VCS provider", } } } @@ -758,7 +768,11 @@ impl DeclarativePluginManifest { if self.plugin_api_version >= CAPABILITIES_API_VERSION { capabilities::check_used(&self.requires, &self.used_capabilities())?; } - if self.languages.is_empty() && self.ui.is_none() && self.build_systems.is_empty() { + if self.languages.is_empty() + && self.ui.is_none() + && self.build_systems.is_empty() + && self.vcs_providers.is_empty() + { return Err(invalid("languages", "The plugin declares no languages.")); } @@ -884,6 +898,7 @@ impl DeclarativePluginManifest { && self.execution_providers.is_empty() && self.ui.is_none() && self.build_systems.is_empty() + && self.vcs_providers.is_empty() { return Err(invalid( "languages", @@ -901,13 +916,20 @@ impl DeclarativePluginManifest { language_servers: &server_languages, }, )?; + crate::vcs::validate_declarations( + &mut self.vcs_providers, + &crate::vcs::DeclarationContext { + plugin_api_version: self.plugin_api_version, + declared_settings: &declared_settings, + }, + )?; self.validate_permissions()?; self.validate_activation_events()?; Ok(self) } /// Capabilities whose sections the manifest declares. Every language - /// server and every own build provider is a `serverProcess`. + /// server, own build provider, and VCS provider is a `serverProcess`. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ ( @@ -935,6 +957,7 @@ impl DeclarativePluginManifest { ( capabilities::SERVER_PROCESS, !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() || self .build_systems .iter() @@ -956,6 +979,7 @@ impl DeclarativePluginManifest { .iter() .any(|system| system.provider.is_some()), ), + (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), ]; sections .into_iter() @@ -1078,6 +1102,7 @@ impl DeclarativePluginManifest { // A provider process of its own is a server process like a language // server, so it needs the same consent. if !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() || self .build_systems .iter() @@ -1096,6 +1121,9 @@ impl DeclarativePluginManifest { if !self.build_systems.is_empty() { required.insert(PERMISSION_BUILD); } + if !self.vcs_providers.is_empty() { + required.insert(PERMISSION_VCS); + } for permission in required { if !self .permissions @@ -1128,6 +1156,9 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { expected.insert(crate::build::activation_event(build_system)); } + for provider in &self.vcs_providers { + expected.insert(crate::vcs::activation_event(provider)); + } if let Some(ui) = &self.ui { for window in &ui.tool_windows { expected.insert(format!("onView:{}", window.id)); @@ -1190,6 +1221,11 @@ impl DeclarativePluginManifest { &self.build_systems } + /// Validated VCS providers, sorted by VCS ID. + pub(crate) fn vcs_providers(&self) -> &[crate::vcs::VcsProviderDeclaration] { + &self.vcs_providers + } + /// Checks the declared settings, sorts them by ID, and returns the IDs /// launch templates may refer to. fn validate_settings(&mut self) -> Validation { @@ -1339,6 +1375,9 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { programs.extend(crate::build::programs(build_system)); } + for provider in &self.vcs_providers { + programs.extend(crate::vcs::programs(provider)); + } // Order by kind, then language or tool, then the full serialized // value as a total tie-breaker; duplicates (two servers needing the // same runtime) collapse to one entry. @@ -1764,6 +1803,7 @@ impl ServerProcess { let suffix = match role { ServerRole::LanguageServer => "LanguageServer", ServerRole::BuildProvider => "BuildProvider", + ServerRole::VcsProvider => "VcsProvider", }; let mut program = match &self.launch { LanguageServerLaunch::Packaged { path } => json!({ @@ -2634,6 +2674,29 @@ fn build_provider_launch_plan( Ok(plan) } +/// The launch plan of a VCS provider. `vcsProvider` repeats what the host +/// needs to resolve the client tool and to check the provider's answer in +/// `initialize`, which must name the same VCS. +fn vcs_provider_launch_plan( + manifest: &DeclarativePluginManifest, + vcs_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let provider = manifest + .vcs_providers + .iter() + .find(|provider| provider.id == vcs_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin declares no VCS provider {vcs_id}."), + ) + })?; + let mut plan = server_launch_plan(manifest, &provider.process, None, request)?; + plan["vcsProvider"] = provider.descriptor(&manifest.id); + Ok(plan) +} + fn server_launch_plan( manifest: &DeclarativePluginManifest, process: &ServerProcess, @@ -3168,8 +3231,8 @@ struct PlanPayload { request: Request, } -/// A launch plan names the process by the language whose server it is, or -/// by the build system whose provider it is. +/// A launch plan names the process by the language whose server it is, by +/// the build system whose provider it is, or by the VCS it provides. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LaunchPlanPayload { @@ -3178,12 +3241,15 @@ struct LaunchPlanPayload { language_id: Option, #[serde(default, rename = "buildSystemID")] build_system_id: Option, + #[serde(default, rename = "vcsProviderID")] + vcs_provider_id: Option, request: LanguageServerLaunchPlanRequest, } enum LaunchSelection<'a> { Language(&'a str), BuildSystem(&'a str), + VcsProvider(&'a str), } pub(crate) fn decode_payload Deserialize<'de>>( @@ -3266,15 +3332,20 @@ pub fn test_plan_command(payload: Value) -> Result { pub fn language_server_launch_plan_command(payload: Value) -> Result { const COMMAND: &str = "plugins.declarative.languageServerLaunchPlan"; let payload: LaunchPlanPayload = decode_payload(payload, COMMAND)?; - let selected = match (&payload.language_id, &payload.build_system_id) { - (Some(language_id), None) => LaunchSelection::Language(language_id), - (None, Some(build_system_id)) => LaunchSelection::BuildSystem(build_system_id), + let selected = match ( + &payload.language_id, + &payload.build_system_id, + &payload.vcs_provider_id, + ) { + (Some(language_id), None, None) => LaunchSelection::Language(language_id), + (None, Some(build_system_id), None) => LaunchSelection::BuildSystem(build_system_id), + (None, None, Some(vcs_id)) => LaunchSelection::VcsProvider(vcs_id), _ => { return Err(CoreError::new( ErrorCode::InvalidRequest, format!("Invalid {COMMAND} payload"), ) - .with_details("Name exactly one of languageID and buildSystemID.")) + .with_details("Name exactly one of languageID, buildSystemID, and vcsProviderID.")) } }; Ok(outcome(validate_manifest_value(payload.manifest).and_then( @@ -3286,6 +3357,9 @@ pub fn language_server_launch_plan_command(payload: Value) -> Result { build_provider_launch_plan(&manifest, build_system_id, &payload.request) } + LaunchSelection::VcsProvider(vcs_id) => { + vcs_provider_launch_plan(&manifest, vcs_id, &payload.request) + } } .map(|plan| ("plan", plan)) }, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 094f9d984e..2ce9d44ace 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -112,6 +112,12 @@ pub enum CoreCommand { BuildSyncFingerprint, /// Maps build options to the Java language server's project import (`build.languageImport`). BuildLanguageImport, + /// Detects VCS roots and registered providers of a workspace (`vcs.detectRoots`). + VcsDetectRoots, + /// Assigns workspace paths to detected VCS roots (`vcs.ownerOf`). + VcsOwnerOf, + /// Validates and normalizes a VCS provider's reply (`vcs.decodeReply`). + VcsDecodeReply, /// Validates and filters a marketplace index (`plugins.marketplace.validateIndex`). PluginsMarketplaceValidateIndex, /// Resolves a user-entered marketplace source URL (`plugins.marketplace.resolveSource`). @@ -276,6 +282,8 @@ pub enum CoreCommand { LspRequest, /// Queues one build-provider request (`lsp.buildRequest`). LspBuildRequest, + /// Queues one VCS-provider request (`lsp.vcsRequest`). + LspVcsRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, /// Queues shared Java gutter marker resolution (`java.navigationMarkers`). @@ -481,6 +489,9 @@ impl CoreCommand { "build.decodeDependencies" => Some(Self::BuildDecodeDependencies), "build.syncFingerprint" => Some(Self::BuildSyncFingerprint), "build.languageImport" => Some(Self::BuildLanguageImport), + "vcs.detectRoots" => Some(Self::VcsDetectRoots), + "vcs.ownerOf" => Some(Self::VcsOwnerOf), + "vcs.decodeReply" => Some(Self::VcsDecodeReply), "plugins.marketplace.validateIndex" => Some(Self::PluginsMarketplaceValidateIndex), "plugins.marketplace.resolveSource" => Some(Self::PluginsMarketplaceResolveSource), "plugins.marketplace.installReview" => Some(Self::PluginsMarketplaceInstallReview), @@ -561,6 +572,7 @@ impl CoreCommand { "lsp.closeDocument" => Some(Self::LspCloseDocument), "lsp.request" => Some(Self::LspRequest), "lsp.buildRequest" => Some(Self::LspBuildRequest), + "lsp.vcsRequest" => Some(Self::LspVcsRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), "java.resolveNavigation" => Some(Self::JavaResolveNavigation), "lsp.cancelOperation" => Some(Self::LspCancelOperation), @@ -658,6 +670,7 @@ mod tests { "lsp.closeDocument", "lsp.request", "lsp.buildRequest", + "lsp.vcsRequest", "lsp.functionRequest", "lsp.cancelOperation", "lsp.pollEvents", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index cfe8f743c4..a48b8d3789 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -282,6 +282,15 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::build::build_language_import_command, ), + CoreCommand::VcsDetectRoots => { + plugin_response(id, parsed.payload, crate::vcs::vcs_detect_roots_command) + } + CoreCommand::VcsOwnerOf => { + plugin_response(id, parsed.payload, crate::vcs::vcs_owner_of_command) + } + CoreCommand::VcsDecodeReply => { + plugin_response(id, parsed.payload, crate::vcs::vcs_decode_reply_command) + } CoreCommand::PluginsMarketplaceValidateIndex => plugin_response( id, parsed.payload, @@ -1611,6 +1620,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspVcsRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid VCS-provider request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::vcs_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("VCS-provider response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspFunctionRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index eecd05e983..5592927215 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -24,3 +24,4 @@ mod protocol; mod run_configuration; mod spring; pub(crate) mod support; +mod vcs; diff --git a/rust/lithe-core/src/tests/vcs.rs b/rust/lithe-core/src/tests/vcs.rs new file mode 100644 index 0000000000..6fad30d8d2 --- /dev/null +++ b/rust/lithe-core/src/tests/vcs.rs @@ -0,0 +1,401 @@ +//! VCS providers declared by API level 3 plugins: manifest validation, +//! install review, launch plans, root detection over real directories, and +//! reply decoding, checked against `shared/fixtures/vcs/model-v1.json` and +//! the Subversion provider manifest fixture. + +use super::support::temporary_root; +use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; +use crate::plugins::marketplace::install_review_command; +use crate::protocol::ErrorCode; +use crate::vcs::{ + negotiated_capabilities, vcs_decode_reply_command, vcs_detect_roots_command, + vcs_owner_of_command, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MODEL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/vcs/model-v1.json" +)); +const SVN_PROVIDER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" +)); + +fn model() -> Value { + serde_json::from_str(MODEL).expect("the VCS model fixture is JSON") +} + +fn svn_provider() -> Value { + serde_json::from_str(SVN_PROVIDER).expect("the provider fixture is JSON") +} + +fn validate(manifest: Value) -> Value { + validate_command(json!({ "manifest": manifest })).expect("well-formed payload") +} + +/// Removes its directory when dropped, so a failed assertion cleans up too. +struct Sandbox(PathBuf); + +impl Sandbox { + fn new(label: &str) -> Self { + let root = temporary_root(label); + fs::create_dir_all(&root).unwrap(); + // Detection reports resolved paths, and the temporary directory is + // itself behind a link on macOS. + Self(root.canonicalize().unwrap()) + } + + fn directory(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(&path).unwrap(); + path + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn the_provider_fixture_validates_and_is_reviewed_with_its_tool() { + let outcome = validate(svn_provider()); + assert_eq!(outcome["status"], "ok", "{outcome}"); + + let review = install_review_command(json!({ "manifest": svn_provider() })).unwrap(); + assert_eq!(review["status"], "ok", "{review}"); + let programs = review["review"]["programs"].as_array().unwrap(); + let provider = programs + .iter() + .find(|program| program["kind"] == "runtimeVcsProvider") + .unwrap_or_else(|| panic!("no provider program: {programs:?}")); + assert_eq!(provider["vcs"], "svn"); + assert_eq!(provider["tool"], "node", "{provider}"); + assert_eq!(provider["entry"], "provider/main.mjs"); + assert_eq!( + provider["vcsTool"], + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }) + ); + assert_eq!(provider["mayUseNetwork"], true); + assert!( + programs.contains(&json!({ "kind": "runtime", "tool": "node", "minimumVersion": "20.3" })), + "{programs:?}" + ); +} + +/// A provider changes the working copy and starts its own process, so the +/// manifest must ask for both, name its activation event, and say which +/// contract versions it needs; Git stays reserved for the built-in support. +#[test] +fn provider_manifests_state_permissions_activation_and_requirements() { + let rejected = |mutate: fn(&mut Value)| { + let mut manifest = svn_provider(); + mutate(&mut manifest); + let outcome = validate(manifest); + assert_eq!(outcome["status"], "rejected", "{outcome}"); + outcome["error"].clone() + }; + let missing_permission = rejected(|manifest| { + manifest["permissions"] = json!(["languageServer.launch"]); + }); + assert_eq!( + missing_permission["field"], "permissions", + "{missing_permission}" + ); + let missing_launch = rejected(|manifest| manifest["permissions"] = json!(["process.vcs"])); + assert_eq!(missing_launch["field"], "permissions", "{missing_launch}"); + let missing_activation = rejected(|manifest| manifest["activationEvents"] = json!([])); + assert_eq!( + missing_activation["field"], "activationEvents", + "{missing_activation}" + ); + let missing_requirement = rejected(|manifest| { + manifest["requires"] = json!({ "serverProcess": 1 }); + }); + assert!( + missing_requirement.to_string().contains("vcsProviders"), + "{missing_requirement}" + ); + let reserved = rejected(|manifest| { + manifest["vcsProviders"][0]["id"] = json!("git"); + manifest["activationEvents"] = json!(["onVcsRoot:git"]); + }); + assert_eq!(reserved["code"], "plugin.vcs.reserved", "{reserved}"); + let level_two = rejected(|manifest| manifest["pluginAPIVersion"] = json!(2)); + assert_ne!(level_two["code"], "plugin.vcs.reserved", "{level_two}"); + let escaping = rejected(|manifest| { + manifest["vcsProviders"][0]["metadataPaths"] = json!(["../wc.db"]); + }); + assert_eq!( + escaping["field"], "vcsProviders[0].metadataPaths[0]", + "{escaping}" + ); + let path_tool = rejected(|manifest| { + manifest["vcsProviders"][0]["tool"]["commands"] = json!(["/usr/bin/svn"]); + }); + assert_eq!( + path_tool["field"], "vcsProviders[0].tool.commands", + "{path_tool}" + ); +} + +#[test] +fn a_launch_plan_names_the_vcs_provider_it_starts() { + let request = json!({ + "pluginDirectory": "/plugins/svn", + "stateDirectory": "/state/svn", + "workspaceStateDirectory": "/state/svn/workspace", + "architecture": "arm64" + }); + let plan = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "svn", + "request": request, + })) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!(plan["vcsProvider"]["vcs"], "svn", "{plan}"); + assert_eq!( + plan["vcsProvider"]["origin"], + json!({ "kind": "plugin", "pluginID": "dev.lithe.fixture.vcs-provider" }) + ); + assert_eq!(plan["vcsProvider"]["metadataPaths"], json!([".svn/wc.db"])); + + let unknown = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "hg", + "request": request, + })) + .unwrap(); + assert_eq!(unknown["status"], "rejected", "{unknown}"); + let both = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "languageID": "java", + "vcsProviderID": "svn", + "request": request, + })) + .unwrap_err(); + assert!(matches!(both.code, ErrorCode::InvalidRequest), "{both:?}"); +} + +fn under(path: &Value, root: &Path) -> bool { + path.as_str() + .is_some_and(|path| Path::new(path).starts_with(root)) +} + +/// Builds each case's markers in a fresh directory and checks the roots, +/// ambiguous directories, unused mappings, and file ownership. Roots found +/// above the case's directory belong to the machine running the test and +/// are left out. +#[test] +fn root_detection_matches_the_contract_cases() { + let model = model(); + for case in model["rootDetectionCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let sandbox = Sandbox::new("vcs-roots"); + for marker in case["markers"].as_array().unwrap() { + let directory = sandbox.directory(marker["directory"].as_str().unwrap()); + let path = directory.join(marker["name"].as_str().unwrap()); + match marker["type"].as_str().unwrap() { + "directory" => fs::create_dir_all(path).unwrap(), + _ => fs::write(path, "gitdir: elsewhere\n").unwrap(), + } + } + let workspace = sandbox.directory(case["workspace"].as_str().unwrap_or("")); + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": workspace.to_string_lossy(), + "plugins": [{ "manifest": svn_provider() }], + "mappings": case.get("mappings").cloned().unwrap_or(json!([])), + })) + .unwrap(); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + let detection = &outcome["detection"]; + assert_eq!(detection["truncated"], false, "{name}"); + + let roots: Vec<&Value> = detection["roots"] + .as_array() + .unwrap() + .iter() + .filter(|root| under(&root["path"], &sandbox.0)) + .collect(); + let found: Vec = roots + .iter() + .map(|root| json!({ "id": root["id"], "vcs": root["vcs"], "mapped": root["mapped"] })) + .collect(); + let expected: Vec = case["expectedRoots"] + .as_array() + .unwrap() + .iter() + .map(|root| { + json!({ + "id": root["id"], + "vcs": root["vcs"], + "mapped": root.get("mapped").cloned().unwrap_or(json!(false)), + }) + }) + .collect(); + assert_eq!(found, expected, "{name}: {detection}"); + assert_eq!(detection["ambiguous"], case["expectedAmbiguous"], "{name}"); + assert_eq!( + detection["unusedMappings"], + case.get("expectedUnusedMappings") + .cloned() + .unwrap_or(json!([])), + "{name}" + ); + + let ownership = case["expectedOwnership"].as_array().unwrap(); + let owners = vcs_owner_of_command(json!({ + "roots": roots.iter().map(|root| root["id"].clone()).collect::>(), + "ambiguous": detection["ambiguous"] + .as_array() + .unwrap() + .iter() + .map(|directory| directory["id"].clone()) + .collect::>(), + "paths": ownership.iter().map(|entry| entry["path"].clone()).collect::>(), + })) + .unwrap(); + assert_eq!(owners["status"], "ok", "{name}: {owners}"); + let expected: Vec = ownership + .iter() + .map(|entry| json!({ "path": entry["path"], "root": entry["root"] })) + .collect(); + assert_eq!(owners["owners"], json!(expected), "{name}"); + } +} + +/// The built-in Git entry and the plugin's descriptor are what a host shows; +/// a second plugin claiming the same VCS loses to the official namespace +/// whatever order the host lists plugins in, and a malformed manifest is +/// reported by its position without hiding the others. +#[test] +fn detection_lists_providers_and_reports_conflicts_and_rejections() { + let model = model(); + let sandbox = Sandbox::new("vcs-providers"); + // `com.` sorts before `dev.`, so only the namespace rule lets the + // official fixture win. + let mut community = svn_provider(); + community["id"] = json!("com.example.svn"); + let mut broken = svn_provider(); + broken["vcsProviders"][0]["id"] = json!("SVN"); + for plugins in [ + vec![community.clone(), broken.clone(), svn_provider()], + vec![svn_provider(), broken.clone(), community.clone()], + ] { + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": sandbox.0.to_string_lossy(), + "plugins": plugins + .into_iter() + .map(|manifest| json!({ "manifest": manifest })) + .collect::>(), + })) + .unwrap(); + let detection = &outcome["detection"]; + let providers = detection["providers"].as_array().unwrap(); + assert_eq!(providers[0], model["providers"][0], "{detection}"); + assert_eq!(providers.len(), 2, "{detection}"); + assert_eq!(providers[1], { + let mut descriptor = model["providers"][1].clone(); + descriptor.as_object_mut().unwrap().remove("capabilities"); + descriptor["metadataPaths"] = json!([".svn/wc.db"]); + descriptor["tool"] = + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }); + descriptor["mayUseNetwork"] = json!(true); + descriptor + }); + assert_eq!( + detection["conflicts"], + json!([{ + "pluginID": "com.example.svn", + "vcs": "svn", + "by": "dev.lithe.fixture.vcs-provider" + }]) + ); + let rejected = detection["rejected"].as_array().unwrap(); + assert_eq!(rejected.len(), 1, "{detection}"); + assert_eq!(rejected[0]["index"], 1); + } + + let error = vcs_detect_roots_command(json!({ "workspaceRoot": "relative/path" })) + .expect_err("a relative workspace is a malformed call"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} + +#[test] +fn ownership_queries_reject_malformed_ids_and_paths() { + for payload in [ + json!({ "roots": ["../a"], "paths": ["x"] }), + json!({ "roots": [""], "ambiguous": ["/abs"], "paths": ["x"] }), + json!({ "roots": [""], "paths": ["../x"] }), + json!({ "roots": [""], "paths": ["a\\b"] }), + ] { + let error = vcs_owner_of_command(payload.clone()).expect_err("malformed"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{payload}"); + } +} + +fn decode(operation: &str, capabilities: &Value, request: &Value, reply: &Value) -> Value { + vcs_decode_reply_command(json!({ + "operation": operation, + "capabilities": capabilities, + "request": request, + "reply": reply, + })) + .expect("well-formed payload") +} + +/// A normalized snapshot is a fixed point: decoding it again changes +/// nothing, so hosts can rely on the fixture as the decoded shape. +#[test] +fn status_snapshots_are_the_decoded_shape() { + let model = model(); + for case in model["statusSnapshots"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let capabilities = &case["capabilities"]; + assert!( + negotiated_capabilities(capabilities).is_some(), + "{name}: the capabilities must be a valid negotiation" + ); + let outcome = decode("status", capabilities, &json!({}), &case["snapshot"]); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["snapshot"], "{name}"); + } + // The Subversion provider's capabilities survive negotiation unchanged. + let svn = &model["providers"][1]["capabilities"]; + assert_eq!(json!(negotiated_capabilities(svn).expect("complete")), *svn); +} + +#[test] +fn plugin_replies_match_the_validation_cases() { + let model = model(); + let capabilities = &model["providers"][1]["capabilities"]; + for case in model["pluginReplyValidationCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let operation = case["operation"].as_str().unwrap_or("status"); + let request = case.get("request").cloned().unwrap_or(json!({})); + let outcome = decode(operation, capabilities, &request, &case["reply"]); + if let Some(code) = case.get("expectedError") { + assert_eq!(outcome["status"], "rejected", "{name}: {outcome}"); + assert_eq!(outcome["error"]["code"], *code, "{name}"); + } else if let Some(changes) = case.get("expectedChanges") { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"]["changes"], *changes, "{name}"); + } else { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["expectedResult"], "{name}"); + } + } + let error = vcs_decode_reply_command(json!({ + "operation": "push", + "capabilities": capabilities, + "reply": {}, + })) + .expect_err("push has no reply shape in version 1"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} diff --git a/rust/lithe-core/src/vcs/commands.rs b/rust/lithe-core/src/vcs/commands.rs new file mode 100644 index 0000000000..9f989092dd --- /dev/null +++ b/rust/lithe-core/src/vcs/commands.rs @@ -0,0 +1,263 @@ +//! The `vcs.*` commands: JSON in, outcome envelope out. +//! +//! Like `build.detect`, `vcs.detectRoots` takes the manifests of the enabled +//! plugins and validates each again, so a host never trusts its own cache of +//! a manifest. Malformed payloads fail the call; rejected manifests and +//! replies come back as `{status: "rejected", error}`. + +use super::manifest::{built_in_git_markers, VcsProviderDeclaration, BUILT_IN_GIT}; +use super::model::{is_root_relative, Capabilities}; +use super::requests::{self, Operation}; +use super::roots::{self, Mapping, MarkerSet}; +use crate::plugins::declarative::{ + decode_payload, is_official_namespace, outcome, validate_manifest_value, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DetectRootsRequest { + /// Absolute workspace root. + workspace_root: String, + /// Manifests of the enabled plugins, in any order. + #[serde(default)] + plugins: Vec, + /// The user's choices for ambiguous directories. + #[serde(default)] + mappings: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DetectPlugin { + manifest: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MappingInput { + id: String, + vcs: String, +} + +/// The declaration that answers for one VCS ID. +struct Owner<'a> { + plugin_id: &'a str, + declaration: &'a VcsProviderDeclaration, +} + +/// Official plugins win a VCS ID, then the lower plugin ID, so the result +/// does not depend on the order a host lists plugins in. +fn precedes(candidate: &Owner<'_>, current: &Owner<'_>) -> bool { + ( + !is_official_namespace(candidate.plugin_id), + candidate.plugin_id, + ) < (!is_official_namespace(current.plugin_id), current.plugin_id) +} + +/// `vcs.detectRoots`: registered providers, the roots of every VCS with a +/// provider (and of the built-in Git), ambiguous directories, and which +/// mappings applied. +pub(crate) fn vcs_detect_roots_command(payload: Value) -> Result { + let request: DetectRootsRequest = decode_payload(payload, "vcs.detectRoots")?; + let workspace = PathBuf::from(&request.workspace_root); + if !workspace.is_absolute() || request.workspace_root.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The workspace root must be an absolute path.", + )); + } + // Ancestors are read from the resolved location: a workspace opened + // through a link sits in the link target's working copy. + let workspace = workspace.canonicalize().unwrap_or(workspace); + + let mut rejected = Vec::new(); + let mut manifests = Vec::new(); + for (index, plugin) in request.plugins.into_iter().enumerate() { + match validate_manifest_value(plugin.manifest) { + Ok(manifest) => manifests.push(manifest), + Err(error) => rejected.push(json!({ "index": index, "error": error })), + } + } + let mut owners: BTreeMap<&str, Owner<'_>> = BTreeMap::new(); + let mut conflicts = Vec::new(); + for manifest in &manifests { + for declaration in manifest.vcs_providers() { + let candidate = Owner { + plugin_id: manifest.id(), + declaration, + }; + let (winner, loser) = match owners.remove(declaration.id.as_str()) { + None => { + owners.insert(&declaration.id, candidate); + continue; + } + Some(current) if precedes(&candidate, ¤t) => (candidate, current), + Some(current) => (current, candidate), + }; + conflicts.push(json!({ + "pluginID": loser.plugin_id, + "vcs": declaration.id, + "by": winner.plugin_id, + })); + owners.insert(&declaration.id, winner); + } + } + conflicts.sort_by_key(Value::to_string); + + let git_markers = built_in_git_markers(); + let mut providers = vec![json!({ + "vcs": BUILT_IN_GIT, + "displayName": "Git", + "origin": { "kind": "builtIn" }, + "markers": git_markers, + })]; + let mut sets = vec![MarkerSet { + vcs: BUILT_IN_GIT, + markers: &git_markers, + }]; + for owner in owners.values() { + providers.push(owner.declaration.descriptor(owner.plugin_id)); + sets.push(MarkerSet { + vcs: &owner.declaration.id, + markers: &owner.declaration.detection.markers, + }); + } + let mappings: Vec = request + .mappings + .into_iter() + .map(|mapping| Mapping { + id: mapping.id, + vcs: mapping.vcs, + }) + .collect(); + let detection = roots::detect(&workspace, &sets, &mappings); + let roots: Vec = detection + .roots + .iter() + .map(|root| { + json!({ + "id": root.id, + "vcs": root.vcs, + "path": root.path.to_string_lossy(), + "mapped": root.mapped, + }) + }) + .collect(); + let ambiguous: Vec = detection + .ambiguous + .iter() + .map(|directory| json!({ "id": directory.id, "vcs": directory.vcs })) + .collect(); + let unused: Vec = detection + .unused_mappings + .iter() + .map(|mapping| json!({ "id": mapping.id, "vcs": mapping.vcs })) + .collect(); + Ok(outcome(Ok(( + "detection", + json!({ + "providers": providers, + "roots": roots, + "ambiguous": ambiguous, + "unusedMappings": unused, + "truncated": detection.truncated, + "rejected": rejected, + "conflicts": conflicts, + }), + )))) +} + +/// Most paths one `vcs.ownerOf` call may ask about. +const MAX_OWNER_PATHS: usize = 100_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OwnerOfRequest { + /// Root IDs of one `vcs.detectRoots` result. + roots: Vec, + /// Its unmapped ambiguous directories. + #[serde(default)] + ambiguous: Vec, + /// Workspace-relative paths with `/` separators; `""` is the workspace. + paths: Vec, +} + +/// `vcs.ownerOf`: the root that owns each path under the rule of +/// `vcs.detectRoots`, so every host assigns files the same way. +pub(crate) fn vcs_owner_of_command(payload: Value) -> Result { + let request: OwnerOfRequest = decode_payload(payload, "vcs.ownerOf")?; + if request.paths.len() > MAX_OWNER_PATHS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("One call asks about at most {MAX_OWNER_PATHS} paths."), + )); + } + if let Some(id) = request + .roots + .iter() + .chain(&request.ambiguous) + .find(|id| !roots::is_directory_id(id)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed root ID").with_details(id.clone()), + ); + } + if let Some(path) = request + .paths + .iter() + .find(|path| !path.is_empty() && !is_root_relative(path)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed workspace path") + .with_details(path.clone()), + ); + } + let owners: Vec = request + .paths + .iter() + .map(|path| { + json!({ + "path": path, + "root": roots::owner(&request.roots, &request.ambiguous, path), + }) + }) + .collect(); + Ok(outcome(Ok(("owners", json!(owners))))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodeReplyRequest { + operation: String, + /// Capabilities the session negotiated, as Core reported them. + capabilities: Capabilities, + /// The arguments the host sent with the request. + #[serde(default)] + request: Value, + /// The provider's result, unwrapped from the session's `value`. + reply: Value, +} + +/// `vcs.decodeReply`: a provider's reply as the normalized result, or +/// `plugin.vcs.invalidOutput`. +pub(crate) fn vcs_decode_reply_command(payload: Value) -> Result { + let request: DecodeReplyRequest = decode_payload(payload, "vcs.decodeReply")?; + let operation = Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + Ok(outcome( + requests::decode( + operation, + &request.request, + &request.reply, + &request.capabilities, + ) + .map(|result| ("result", result)), + )) +} diff --git a/rust/lithe-core/src/vcs/manifest.rs b/rust/lithe-core/src/vcs/manifest.rs new file mode 100644 index 0000000000..9b0d024254 --- /dev/null +++ b/rust/lithe-core/src/vcs/manifest.rs @@ -0,0 +1,286 @@ +//! `vcsProviders[]` of an API level 3 plugin manifest: what a host must know +//! about a VCS before starting any process for it. +//! +//! A declaration names the VCS, the static markers that identify its roots, +//! the metadata the host watches to refresh status, the client tool the +//! provider drives, and the provider process with the launch fields every +//! plugin server process shares. Capabilities are not declared here: they +//! belong to the running provider and are negotiated in `initialize`. + +use super::model::is_vcs_id; +use crate::plugins::declarative::{ + invalid, is_bare_command, is_safe_relative_path, sorted_unique, PluginRejection, ServerProcess, + ServerRole, Validation, +}; +use crate::plugins::settings::DeclaredSettings; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// The built-in VCS ID. Git is not part of the unified layer yet, but Core +/// recognizes its roots so a nested working copy is assigned correctly, and +/// no plugin may claim the ID. +pub(crate) const BUILT_IN_GIT: &str = "git"; + +/// Most providers one plugin may declare. +const MAX_PROVIDERS: usize = 8; +/// Most markers one provider may declare. +const MAX_MARKERS: usize = 8; +/// Most metadata paths one provider may ask the host to watch. +const MAX_METADATA_PATHS: usize = 16; +/// Most tool command names one provider may list. +const MAX_TOOL_COMMANDS: usize = 8; +/// Longest display name, in characters. +const MAX_DISPLAY_NAME: usize = 64; + +/// One VCS a plugin implements. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsProviderDeclaration { + /// Stable lowercase VCS ID (`svn`, `hg`); the provider must answer the + /// same ID in `initialize`. + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) detection: VcsDetection, + /// Root-relative files or directories whose change means the status may + /// be stale, such as `.svn/wc.db`. Sorted and unique once validated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) metadata_paths: Vec, + /// The VCS client the provider runs, which the host resolves and passes + /// with every request. Absent for a provider that needs no client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + #[serde(flatten)] + pub(crate) process: ServerProcess, + /// Whether operations may contact a server; shown in the install review. + pub(crate) may_use_network: bool, +} + +/// Static markers of a root. Detection never starts a process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsDetection { + pub(crate) markers: Vec, +} + +/// An entry directly inside a root that marks it, such as the `.svn` +/// directory. The kind matters: a Git worktree has a `.git` file. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsMarker { + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) kind: MarkerKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MarkerKind { + Directory, + File, +} + +/// A client executable the host discovers like a `systemCommand` launch. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsTool { + /// Bare command names, tried in order. + pub(crate) commands: Vec, + /// Arguments of a probe that must exit successfully before the host + /// trusts a candidate; empty means no probe. + #[serde(default)] + pub(crate) validation_arguments: Vec, +} + +/// The markers Core uses for the built-in Git support: a `.git` directory, +/// or the `.git` file of a linked worktree or submodule. +pub(crate) fn built_in_git_markers() -> Vec { + vec![ + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::Directory, + }, + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::File, + }, + ] +} + +fn is_marker_name(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.len() <= 255 + && !value.contains(['/', '\\', '\0']) +} + +/// What the declaration context needs from the manifest. +pub(crate) struct DeclarationContext<'a> { + pub(crate) plugin_api_version: u32, + pub(crate) declared_settings: &'a DeclaredSettings, +} + +/// Validates every declaration and sorts them by ID. +pub(crate) fn validate_declarations( + providers: &mut [VcsProviderDeclaration], + context: &DeclarationContext<'_>, +) -> Validation<()> { + if providers.is_empty() { + return Ok(()); + } + if context.plugin_api_version < 3 { + return Err(invalid( + "vcsProviders", + "VCS providers require plugin API level 3.", + )); + } + if providers.len() > MAX_PROVIDERS { + return Err(invalid( + "vcsProviders", + format!("A plugin declares at most {MAX_PROVIDERS} VCS providers."), + )); + } + let mut ids = BTreeSet::new(); + for (index, provider) in providers.iter_mut().enumerate() { + let field = format!("vcsProviders[{index}]"); + provider.validate(&field, context)?; + if !ids.insert(provider.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "A VCS is declared more than once.", + )); + } + } + providers.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(()) +} + +impl VcsProviderDeclaration { + fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + if !is_vcs_id(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A VCS ID is a lowercase letter followed by up to 31 lowercase letters, digits, or hyphens.", + )); + } + if self.id == BUILT_IN_GIT { + return Err(PluginRejection::at( + "plugin.vcs.reserved", + format!("{field}.id"), + "Git is built into Lithe and cannot be provided by a plugin yet.", + )); + } + let display_name = self.display_name.trim(); + if display_name.is_empty() + || display_name.chars().count() > MAX_DISPLAY_NAME + || display_name.chars().any(char::is_control) + { + return Err(invalid( + format!("{field}.displayName"), + "The display name is one line of 1-64 characters.", + )); + } + + let markers = &mut self.detection.markers; + if markers.is_empty() || markers.len() > MAX_MARKERS { + return Err(invalid( + format!("{field}.detection.markers"), + format!("Detection needs 1-{MAX_MARKERS} markers."), + )); + } + if let Some(index) = markers + .iter() + .position(|marker| !is_marker_name(&marker.name)) + { + return Err(invalid( + format!("{field}.detection.markers[{index}].name"), + "A marker is a name directly inside the root.", + )); + } + markers.sort(); + markers.dedup(); + + if self.metadata_paths.len() > MAX_METADATA_PATHS { + return Err(invalid( + format!("{field}.metadataPaths"), + format!("At most {MAX_METADATA_PATHS} metadata paths are watched."), + )); + } + if let Some(index) = self.metadata_paths.iter().position(|path| { + !is_safe_relative_path(path) || path.split('/').any(|s| s.is_empty() || s == ".") + }) { + return Err(invalid( + format!("{field}.metadataPaths[{index}]"), + "A metadata path must stay inside the root.", + )); + } + self.metadata_paths = sorted_unique(std::mem::take(&mut self.metadata_paths)); + + if let Some(tool) = &self.tool { + if tool.commands.is_empty() + || tool.commands.len() > MAX_TOOL_COMMANDS + || tool + .commands + .iter() + .any(|command| !is_bare_command(command)) + { + return Err(invalid( + format!("{field}.tool.commands"), + format!("A tool names 1-{MAX_TOOL_COMMANDS} bare executable names."), + )); + } + if tool + .validation_arguments + .iter() + .any(|argument| argument.contains('\0')) + { + return Err(invalid( + format!("{field}.tool.validationArguments"), + "Tool arguments must not contain NUL.", + )); + } + } + + self.process.validate( + field, + context.plugin_api_version, + context.declared_settings, + ServerRole::VcsProvider, + ) + } + + /// Display data and requirements a host keeps for a provider; the + /// process launch is planned separately. + pub(crate) fn descriptor(&self, plugin_id: &str) -> Value { + json!({ + "vcs": self.id, + "displayName": self.display_name, + "origin": { "kind": "plugin", "pluginID": plugin_id }, + "markers": self.detection.markers, + "metadataPaths": self.metadata_paths, + "tool": self.tool, + "mayUseNetwork": self.may_use_network, + }) + } +} + +/// Activation event the host raises before starting a VCS provider, once a +/// root of its VCS was detected. +pub(crate) fn activation_event(provider: &VcsProviderDeclaration) -> String { + format!("onVcsRoot:{}", provider.id) +} + +/// Install-review entries: the provider process with the runtimes it needs. +/// The tool is part of the provider entry, so pointing it at another client +/// is a new program the user must approve. +pub(crate) fn programs(provider: &VcsProviderDeclaration) -> Vec { + let mut program = provider.process.program(ServerRole::VcsProvider); + program["vcs"] = json!(provider.id); + // `tool` already names a runtime launch's runtime. + program["vcsTool"] = json!(provider.tool); + program["mayUseNetwork"] = json!(provider.may_use_network); + let mut programs = vec![program]; + programs.extend(provider.process.runtime_programs()); + programs +} diff --git a/rust/lithe-core/src/vcs/mod.rs b/rust/lithe-core/src/vcs/mod.rs new file mode 100644 index 0000000000..18dc26112c --- /dev/null +++ b/rust/lithe-core/src/vcs/mod.rs @@ -0,0 +1,29 @@ +//! The unified VCS layer: one model of roots, file status, history, and +//! check-in that every version control system maps onto, and the +//! deterministic half of talking to a plugin VCS provider. +//! +//! A provider is a plugin process speaking the LSP base protocol, like a +//! build provider. Core validates its `vcsProviders` declaration, detects +//! roots from static markers, plans each request from an operation name and +//! checked arguments, and validates every reply before a host renders it. +//! Hosts resolve the client tool, start and stop processes, watch metadata, +//! and own every piece of UI and state such as changelists. +//! +//! Git is not part of this layer yet: Core recognizes `.git` markers so a +//! nested working copy is assigned to the right root, and the existing +//! `git.*` commands keep serving Git roots unchanged. +//! +//! Note: the design is recorded in .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +mod commands; +mod manifest; +mod model; +mod requests; +mod roots; + +pub(crate) use commands::*; +pub(crate) use manifest::{ + activation_event, programs, validate_declarations, DeclarationContext, VcsProviderDeclaration, +}; +pub(crate) use model::{is_vcs_id, negotiated_capabilities, Capabilities}; +pub(crate) use requests::{is_vcs_method, plan, Operation}; diff --git a/rust/lithe-core/src/vcs/model.rs b/rust/lithe-core/src/vcs/model.rs new file mode 100644 index 0000000000..9fc128da66 --- /dev/null +++ b/rust/lithe-core/src/vcs/model.rs @@ -0,0 +1,726 @@ +//! The VCS-neutral model: capability IDs, file statuses, revisions, and the +//! validation that turns a plugin provider's reply into a result both hosts +//! can render without trusting the provider. +//! +//! Replies are untrusted input. A value outside the contract fails the reply +//! with `plugin.vcs.invalidOutput`; optional data Core does not know (a flag, +//! a field) is dropped, so a newer provider can add it within version 1. +//! Lists the host renders as sets are sorted here, and every list is capped, +//! so both hosts show the same result for the same reply. + +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Error code of every reply that fails validation or exceeds a limit. +pub(crate) const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; + +/// Capability IDs of protocol version 1. `true` marks the capabilities +/// every provider must support; a provider without one of them is not used. +pub(crate) const CAPABILITIES: &[(&str, bool)] = &[ + ("annotate", false), + ("baseContent", true), + ("checkin", true), + ("cleanup", false), + ("conflicts", false), + ("fileHistory", false), + ("fileOperations", false), + ("history", false), + ("push", false), + ("rollback", true), + ("staging", false), + ("status", true), + ("update", false), +]; + +/// Version of each capability this Core build understands. +const CAPABILITY_VERSION: u64 = 1; + +/// A snapshot keeps at most this many changes and is then marked truncated. +pub(crate) const MAX_CHANGES: usize = 100_000; +/// Largest base-revision content, in UTF-8 bytes, a reply may carry. +pub(crate) const MAX_BASE_CONTENT_BYTES: usize = 32 * 1024 * 1024; +/// Most entries one history page may return; the request limit is lower. +pub(crate) const MAX_HISTORY_PAGE: usize = 500; +/// Most changed paths one history entry may list. +const MAX_ENTRY_CHANGES: usize = 10_000; +/// Most lines an annotation may cover. +pub(crate) const MAX_ANNOTATED_LINES: usize = 500_000; +/// Most distinct revisions one annotation may reference. +const MAX_ANNOTATION_REVISIONS: usize = 100_000; +/// Longest revision ID, opaque cursor, branch, or author, in characters. +const MAX_SHORT_TEXT: usize = 256; +/// Longest revision display text, in characters. +const MAX_REVISION_DISPLAY: usize = 64; +/// Longest commit message, in UTF-8 bytes. +pub(crate) const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +/// Longest remote location after sanitizing, in characters. +const MAX_LOCATION: usize = 2048; +/// Longest root-relative path, in UTF-8 bytes. +const MAX_PATH_BYTES: usize = 4096; + +/// File status values of version 1. A reply with any other value fails. +const STATUSES: &[&str] = &[ + "added", + "conflicted", + "copied", + "deleted", + "ignored", + "missing", + "modified", + "renamed", + "replaced", + "switched", + "typeChanged", + "unversioned", +]; + +/// Statuses whose change names the path it came from. +const STATUSES_WITH_ORIGIN: &[&str] = &["copied", "renamed"]; + +/// Refinements of a status. Unknown flags are dropped, not rejected. +const FLAGS: &[&str] = &[ + "external", + "locked", + "lockedByOther", + "propertiesModified", + "submodule", + "treeConflict", +]; + +/// Staged state of a change; only providers with `staging` report it. +const STAGING: &[&str] = &["partial", "staged", "unstaged"]; + +/// How a host presents a revision ID; never used to order revisions. +const REVISION_KINDS: &[&str] = &["hash", "number", "opaque"]; + +/// Capabilities a provider negotiated, by ID, with their versions. +pub(crate) type Capabilities = BTreeMap; + +fn invalid_output(message: impl Into) -> PluginRejection { + PluginRejection::new(INVALID_OUTPUT, message) +} + +/// Whether `id` is a capability of protocol version 1. +pub(crate) fn is_capability(id: &str) -> bool { + CAPABILITIES.iter().any(|(known, _)| *known == id) +} + +/// The capabilities of a provider's `initialize` answer that this build +/// understands, or `None` when a required one is missing or the value is +/// not an object. Unknown IDs and versions are dropped: a newer provider may +/// offer more, and the host then hides only what Core cannot validate. +pub(crate) fn negotiated_capabilities(value: &Value) -> Option { + let offered = value.as_object()?; + let capabilities: Capabilities = offered + .iter() + .filter(|(id, version)| is_capability(id) && version.as_u64() == Some(CAPABILITY_VERSION)) + .map(|(id, _)| (id.clone(), CAPABILITY_VERSION)) + .collect(); + CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .all(|(id, _)| capabilities.contains_key(*id)) + .then_some(capabilities) +} + +/// Whether a stable VCS ID is well formed: a lowercase letter, then up to +/// 31 lowercase letters, digits, or hyphens (`git`, `svn`, `hg`). +pub(crate) fn is_vcs_id(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.len() <= 32 + && characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +/// Whether `value` is a path inside a root: relative, `/`-separated, and +/// without empty, `.`, or `..` segments that could leave or alias the root. +pub(crate) fn is_root_relative(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_PATH_BYTES + && !value.starts_with('/') + && !value.contains('\\') + && !value.chars().any(char::is_control) + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") + // A drive prefix would make the path absolute on Windows. + && !value.split('/').next().is_some_and(|first| first.ends_with(':')) +} + +/// Removes user info, query, and fragment from a remote location, so a URL +/// that carries credentials never leaves Core. Locations without a scheme +/// (a local repository path) are kept apart from the query and fragment. +pub(crate) fn sanitize_location(value: &str) -> Option { + let value = value.split(['?', '#']).next().unwrap_or_default(); + let sanitized = match value.split_once("://") { + Some((scheme, rest)) => { + let (authority, path) = rest + .find('/') + .map_or((rest, ""), |index| rest.split_at(index)); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + format!("{scheme}://{host}{path}") + } + None => value.to_string(), + }; + (!sanitized.is_empty() + && sanitized.chars().count() <= MAX_LOCATION + && !sanitized.chars().any(char::is_control)) + .then_some(sanitized) +} + +fn object<'a>(value: &'a Value, what: &str) -> Validation<&'a Map> { + value + .as_object() + .ok_or_else(|| invalid_output(format!("The {what} must be an object."))) +} + +fn short_text(value: &str, limit: usize) -> bool { + !value.is_empty() && value.chars().count() <= limit && !value.chars().any(char::is_control) +} + +/// An optional string field: absent and `null` are the same, anything but a +/// short single-line string fails. +fn optional_text( + members: &Map, + key: &str, + limit: usize, + what: &str, +) -> Validation> { + match members.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) if short_text(text, limit) => Ok(Some(text.clone())), + Some(_) => Err(invalid_output(format!( + "The {what} {key} must be a single-line string of at most {limit} characters." + ))), + } +} + +fn root_path(value: Option<&Value>, what: &str) -> Validation { + match value { + Some(Value::String(path)) if is_root_relative(path) => Ok(path.clone()), + _ => Err(invalid_output(format!( + "The {what} must be a path inside the root." + ))), + } +} + +/// A revision as `{id, display, kind}`. `display` falls back to the ID. +pub(crate) fn revision(value: &Value) -> Validation { + let members = object(value, "revision")?; + let id = match members.get("id") { + Some(Value::String(id)) if short_text(id, MAX_SHORT_TEXT) => id.clone(), + _ => { + return Err(invalid_output( + "A revision ID must be a single-line string of at most 256 characters.", + )) + } + }; + let display = optional_text(members, "display", MAX_REVISION_DISPLAY, "revision")? + .unwrap_or_else(|| id.chars().take(MAX_REVISION_DISPLAY).collect()); + let kind = match members.get("kind") { + None | Some(Value::Null) => "opaque", + Some(Value::String(kind)) if REVISION_KINDS.contains(&kind.as_str()) => kind.as_str(), + Some(_) => { + return Err(invalid_output( + "A revision kind must be hash, number, or opaque.", + )) + } + }; + Ok(json!({ "id": id, "display": display, "kind": kind })) +} + +fn optional_revision(value: Option<&Value>) -> Validation { + match value { + None | Some(Value::Null) => Ok(Value::Null), + Some(value) => revision(value), + } +} + +/// One change of a status snapshot or a history entry. +fn change(value: &Value, staging: bool) -> Validation { + let members = object(value, "change")?; + let path = root_path(members.get("path"), "change path")?; + let status = match members.get("status") { + Some(Value::String(status)) if STATUSES.contains(&status.as_str()) => status.clone(), + Some(Value::String(status)) => { + return Err(invalid_output(format!( + "The file status {status} is not part of the contract." + ))) + } + _ => return Err(invalid_output("A change needs a status.")), + }; + let old_path = match members.get("oldPath") { + None | Some(Value::Null) => Value::Null, + // Only a copy or rename names where the file came from; anything + // else would make the host show a move that never happened. + Some(value) if STATUSES_WITH_ORIGIN.contains(&status.as_str()) => { + json!(root_path(Some(value), "original path")?) + } + Some(_) => { + return Err(invalid_output(format!( + "A {status} change cannot name an original path." + ))) + } + }; + let flags: BTreeSet<&str> = match members.get("flags") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(flags)) => flags + .iter() + .filter_map(Value::as_str) + .filter(|flag| FLAGS.contains(flag)) + .collect(), + Some(_) => return Err(invalid_output("Change flags must be an array.")), + }; + let mut normalized = json!({ + "path": path, + "oldPath": old_path, + "status": status, + "flags": flags, + }); + match (members.get("staging"), staging) { + (None | Some(Value::Null), false) => {} + (Some(Value::String(state)), true) if STAGING.contains(&state.as_str()) => { + normalized["staging"] = json!(state); + } + (_, true) => return Err(invalid_output( + "A provider with staging must report staged, unstaged, or partial for every change.", + )), + (Some(_), false) => { + return Err(invalid_output( + "A change carries staging although the provider did not negotiate staging.", + )) + } + } + Ok(normalized) +} + +/// Validated changes sorted by path; duplicate paths fail the reply. +fn changes(value: Option<&Value>, staging: bool, limit: usize) -> Validation<(Vec, bool)> { + let items = match value { + Some(Value::Array(items)) => items, + None | Some(Value::Null) => return Ok((Vec::new(), false)), + Some(_) => return Err(invalid_output("Changes must be an array.")), + }; + let mut by_path = BTreeMap::new(); + for item in items { + let change = change(item, staging)?; + let path = change["path"].as_str().unwrap_or_default().to_string(); + if by_path.insert(path.clone(), change).is_some() { + return Err(invalid_output(format!( + "The path {path} is reported more than once." + ))); + } + } + let truncated = by_path.len() > limit; + Ok((by_path.into_values().take(limit).collect(), truncated)) +} + +fn boolean(members: &Map, key: &str) -> Validation { + match members.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(invalid_output(format!("{key} must be a boolean."))), + } +} + +/// `lithe/vcs/status`: `{head, changes, truncated}` with changes sorted by +/// path and capped at [`MAX_CHANGES`]. +pub(crate) fn decode_status(reply: &Value, capabilities: &Capabilities) -> Validation { + let members = object(reply, "status reply")?; + let head = match members.get("head") { + None | Some(Value::Null) => json!({ "revision": null, "branch": null, "location": null }), + Some(head) => { + let head = object(head, "head")?; + let location = optional_text(head, "location", MAX_LOCATION, "head")? + .map(|location| { + sanitize_location(&location) + .ok_or_else(|| invalid_output("The head location is not a location.")) + }) + .transpose()?; + json!({ + "revision": optional_revision(head.get("revision"))?, + "branch": optional_text(head, "branch", MAX_SHORT_TEXT, "head")?, + "location": location, + }) + } + }; + let (changes, capped) = changes( + members.get("changes"), + capabilities.contains_key("staging"), + MAX_CHANGES, + )?; + Ok(json!({ + "head": head, + "changes": changes, + // A provider that stopped early says so; Core adds its own cap. + "truncated": capped || boolean(members, "truncated")?, + })) +} + +/// `lithe/vcs/baseContent`: the file's text at its base revision, `null` +/// content when it has none (an added file), and `binary` when the host +/// must not diff it as text. +pub(crate) fn decode_base_content(reply: &Value) -> Validation { + let members = object(reply, "base content reply")?; + let binary = boolean(members, "binary")?; + let content = match members.get("content") { + None | Some(Value::Null) => Value::Null, + Some(Value::String(_)) if binary => { + return Err(invalid_output( + "Binary base content is reported without text.", + )) + } + Some(Value::String(text)) if text.len() <= MAX_BASE_CONTENT_BYTES => json!(text), + Some(Value::String(_)) => return Err(invalid_output("The base content exceeds 32 MiB.")), + Some(_) => return Err(invalid_output("content must be a string or null.")), + }; + Ok(json!({ "content": content, "binary": binary })) +} + +fn message(members: &Map) -> Validation { + match members.get("message") { + None | Some(Value::Null) => Ok(String::new()), + Some(Value::String(text)) if text.len() <= MAX_MESSAGE_BYTES && !text.contains('\0') => { + Ok(text.clone()) + } + Some(_) => Err(invalid_output( + "A message must be a string of at most 1 MiB without NUL.", + )), + } +} + +/// Author, date, and message of one revision. Dates are passed through as +/// the provider wrote them (RFC 3339); hosts format but never compare them. +fn revision_details(members: &Map) -> Validation { + Ok(json!({ + "revision": revision(members.get("revision").unwrap_or(&Value::Null))?, + "author": optional_text(members, "author", MAX_SHORT_TEXT, "revision")?, + "date": optional_text(members, "date", MAX_SHORT_TEXT, "revision")?, + "message": message(members)?, + })) +} + +/// `lithe/vcs/history` and `lithe/vcs/fileHistory`: one page of entries in +/// the provider's order (newest first), and the cursor of the next page. +pub(crate) fn decode_history(reply: &Value, limit: usize) -> Validation { + let members = object(reply, "history reply")?; + let items = match members.get("entries") { + Some(Value::Array(items)) => items, + _ => return Err(invalid_output("A history reply needs an entries array.")), + }; + // The provider received the limit; more entries means it ignored it. + if items.len() > limit.min(MAX_HISTORY_PAGE) { + return Err(invalid_output(format!( + "The history page has more than the {limit} requested entries." + ))); + } + let mut entries = Vec::with_capacity(items.len()); + for item in items { + let entry = object(item, "history entry")?; + let mut normalized = revision_details(entry)?; + let (changes, truncated) = changes(entry.get("changes"), false, MAX_ENTRY_CHANGES)?; + normalized["changes"] = json!(changes); + normalized["changesTruncated"] = json!(truncated || boolean(entry, "changesTruncated")?); + entries.push(normalized); + } + let next_cursor = optional_text(members, "nextCursor", MAX_SHORT_TEXT, "history")?; + Ok(json!({ "entries": entries, "nextCursor": next_cursor })) +} + +/// `lithe/vcs/annotate`: the revisions a file's lines come from, and per +/// line the ID of one of them, or `null` for a line not yet checked in. +pub(crate) fn decode_annotation(reply: &Value) -> Validation { + let members = object(reply, "annotation reply")?; + let (Some(Value::Array(revisions)), Some(Value::Array(lines))) = + (members.get("revisions"), members.get("lines")) + else { + return Err(invalid_output( + "An annotation needs revisions and lines arrays.", + )); + }; + if revisions.len() > MAX_ANNOTATION_REVISIONS || lines.len() > MAX_ANNOTATED_LINES { + return Err(invalid_output("The annotation exceeds its size limit.")); + } + let mut by_id = BTreeMap::new(); + for item in revisions { + let details = revision_details(object(item, "annotation revision")?)?; + let id = details["revision"]["id"] + .as_str() + .unwrap_or_default() + .to_string(); + if by_id.insert(id.clone(), details).is_some() { + return Err(invalid_output(format!( + "The revision {id} is listed more than once." + ))); + } + } + let mut used = BTreeSet::new(); + let mut normalized_lines = Vec::with_capacity(lines.len()); + for line in lines { + match line { + Value::Null => normalized_lines.push(Value::Null), + Value::String(id) if by_id.contains_key(id) => { + used.insert(id.clone()); + normalized_lines.push(json!(id)); + } + _ => { + return Err(invalid_output( + "Every annotated line names a listed revision or null.", + )) + } + } + } + // Revisions no line uses would show up in a legend with nothing to point + // at; sorting by ID keeps the list stable without ordering revisions. + let revisions: Vec = by_id + .into_iter() + .filter(|(id, _)| used.contains(id)) + .map(|(_, details)| details) + .collect(); + Ok(json!({ "revisions": revisions, "lines": normalized_lines })) +} + +/// `lithe/vcs/checkin`: the new revision, or `null` when the provider +/// cannot name it. +pub(crate) fn decode_checkin(reply: &Value) -> Validation { + let members = object(reply, "check-in reply")?; + Ok(json!({ "revision": optional_revision(members.get("revision"))? })) +} + +/// `lithe/vcs/update`: the revision the working copy is at now, and the +/// paths the update left conflicted, sorted. +pub(crate) fn decode_update(reply: &Value) -> Validation { + let members = object(reply, "update reply")?; + let conflicts: BTreeSet = match members.get("conflicts") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(paths)) => paths + .iter() + .map(|path| root_path(Some(path), "conflicted path")) + .collect::>()?, + Some(_) => return Err(invalid_output("conflicts must be an array of paths.")), + }; + if conflicts.len() > MAX_CHANGES { + return Err(invalid_output("The update reports too many conflicts.")); + } + Ok(json!({ + "revision": optional_revision(members.get("revision"))?, + "conflicts": conflicts, + })) +} + +/// Replies that carry no data. Anything in them is ignored, so a provider +/// may report extra details without breaking an older host. +pub(crate) fn decode_empty(reply: &Value) -> Validation { + match reply { + Value::Null | Value::Object(_) => Ok(json!({})), + _ => Err(invalid_output("The reply must be an object or null.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities(staging: bool) -> Capabilities { + let mut capabilities: Capabilities = CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .map(|(id, _)| (id.to_string(), 1)) + .collect(); + if staging { + capabilities.insert("staging".to_string(), 1); + } + capabilities + } + + #[test] + fn negotiation_keeps_known_version_one_capabilities_and_requires_the_core_set() { + let negotiated = negotiated_capabilities(&json!({ + "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, + "annotate": 2, "futureThing": 1, "update": 1 + })) + .expect("required set present"); + assert_eq!( + negotiated.keys().map(String::as_str).collect::>(), + ["baseContent", "checkin", "rollback", "status", "update"] + ); + // A provider without rollback cannot back the generic commit UI. + assert!( + negotiated_capabilities(&json!({ "status": 1, "baseContent": 1, "checkin": 1 })) + .is_none() + ); + assert!(negotiated_capabilities(&json!(["status"])).is_none()); + } + + #[test] + fn root_relative_paths_cannot_leave_or_alias_the_root() { + for valid in ["a.txt", "src/Main.java", ".svnignore", "a b/c"] { + assert!(is_root_relative(valid), "{valid}"); + } + for invalid in [ + "", + "/etc/passwd", + "../x", + "a/../b", + "a//b", + "./a", + "a/", + "a\\b", + "C:/x", + "a\u{0}b", + ] { + assert!(!is_root_relative(invalid), "{invalid:?}"); + } + } + + #[test] + fn locations_lose_credentials_query_and_fragment() { + assert_eq!( + sanitize_location("https://user:secret@svn.example.invalid/repo/trunk?p=1#f") + .as_deref(), + Some("https://svn.example.invalid/repo/trunk") + ); + assert_eq!( + sanitize_location("svn+ssh://me@host.invalid").as_deref(), + Some("svn+ssh://host.invalid") + ); + assert_eq!( + sanitize_location("file:///var/svn/repo").as_deref(), + Some("file:///var/svn/repo") + ); + assert_eq!(sanitize_location("?only=query"), None); + } + + #[test] + fn status_reply_is_normalized_and_capped() { + let reply = json!({ + "head": { + "revision": { "id": "12", "kind": "number" }, + "location": "https://u:p@svn.example.invalid/r", + "branch": null + }, + "changes": [ + { "path": "b", "status": "modified", "flags": ["propertiesModified", "locked", "x"] }, + { "path": "a", "oldPath": "old", "status": "renamed" } + ] + }); + let snapshot = decode_status(&reply, &capabilities(false)).expect("valid"); + assert_eq!( + snapshot, + json!({ + "head": { + "revision": { "id": "12", "display": "12", "kind": "number" }, + "branch": null, + "location": "https://svn.example.invalid/r" + }, + "changes": [ + { "path": "a", "oldPath": "old", "status": "renamed", "flags": [] }, + { "path": "b", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } + ], + "truncated": false + }) + ); + } + + #[test] + fn status_reply_rejects_contract_violations() { + for reply in [ + json!({ "changes": [{ "path": "a", "status": "modified" }, { "path": "a", "status": "added" }] }), + json!({ "changes": [{ "path": "a", "oldPath": "b", "status": "modified" }] }), + json!({ "changes": [{ "path": "a", "status": "modified", "flags": "locked" }] }), + json!({ "changes": "a" }), + json!({ "head": { "revision": { "id": "" } } }), + json!({ "head": { "revision": { "id": "1", "kind": "date" } } }), + ] { + let error = decode_status(&reply, &capabilities(false)).expect_err("rejected"); + assert_eq!(error.code, INVALID_OUTPUT, "{reply}"); + } + // With staging negotiated every change must say how it is staged. + let error = decode_status( + &json!({ "changes": [{ "path": "a", "status": "modified" }] }), + &capabilities(true), + ) + .expect_err("missing staging"); + assert_eq!(error.code, INVALID_OUTPUT); + } + + #[test] + fn oversized_snapshots_are_marked_truncated() { + let changes: Vec = (0..=MAX_CHANGES) + .map(|index| json!({ "path": format!("f{index:06}"), "status": "unversioned" })) + .collect(); + let snapshot = + decode_status(&json!({ "changes": changes }), &capabilities(false)).expect("valid"); + assert_eq!( + snapshot["changes"].as_array().map(Vec::len), + Some(MAX_CHANGES) + ); + assert_eq!(snapshot["truncated"], json!(true)); + } + + #[test] + fn base_content_distinguishes_text_binary_and_absent() { + assert_eq!( + decode_base_content(&json!({ "content": "x\n" })).expect("text"), + json!({ "content": "x\n", "binary": false }) + ); + assert_eq!( + decode_base_content(&json!({ "content": null, "binary": true })).expect("binary"), + json!({ "content": null, "binary": true }) + ); + assert!(decode_base_content(&json!({ "content": "x", "binary": true })).is_err()); + } + + #[test] + fn history_page_keeps_provider_order_and_enforces_the_limit() { + let reply = json!({ + "entries": [ + { "revision": { "id": "9", "kind": "number" }, "author": "ann", "date": "2026-10-22T08:00:00Z", "message": "second", + "changes": [{ "path": "b", "status": "modified" }, { "path": "a", "status": "added" }] }, + { "revision": { "id": "3", "kind": "number" }, "message": "first" } + ], + "nextCursor": "3" + }); + let page = decode_history(&reply, 2).expect("valid"); + assert_eq!(page["entries"][0]["revision"]["id"], "9"); + assert_eq!(page["entries"][0]["changes"][0]["path"], "a"); + assert_eq!(page["entries"][1]["author"], Value::Null); + assert_eq!(page["nextCursor"], "3"); + assert!(decode_history(&reply, 1).is_err()); + } + + #[test] + fn annotation_lines_must_reference_listed_revisions() { + let reply = json!({ + "revisions": [ + { "revision": { "id": "2" }, "author": "bo" }, + { "revision": { "id": "1" }, "author": "al" }, + { "revision": { "id": "7" } } + ], + "lines": ["1", "2", null, "1"] + }); + let annotation = decode_annotation(&reply).expect("valid"); + assert_eq!(annotation["lines"], json!(["1", "2", null, "1"])); + // Unused revision 7 is dropped; the rest are ordered by ID. + assert_eq!(annotation["revisions"][0]["revision"]["id"], "1"); + assert_eq!(annotation["revisions"].as_array().map(Vec::len), Some(2)); + assert!(decode_annotation(&json!({ "revisions": [], "lines": ["1"] })).is_err()); + } + + #[test] + fn update_reports_sorted_conflicts_inside_the_root() { + assert_eq!( + decode_update(&json!({ "revision": { "id": "5" }, "conflicts": ["b", "a", "b"] })) + .expect("valid"), + json!({ "revision": { "id": "5", "display": "5", "kind": "opaque" }, "conflicts": ["a", "b"] }) + ); + assert!(decode_update(&json!({ "conflicts": ["/abs"] })).is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/requests.rs b/rust/lithe-core/src/vcs/requests.rs new file mode 100644 index 0000000000..4923fe42c4 --- /dev/null +++ b/rust/lithe-core/src/vcs/requests.rs @@ -0,0 +1,503 @@ +//! Operations of the VCS provider protocol: which method and capability each +//! one uses, how a host's arguments become request params, and how the +//! provider's reply is decoded. +//! +//! The host names an operation, never a method, so a session that negotiated +//! the protocol cannot be used as a generic JSON-RPC tunnel. Arguments come +//! from the host and are checked as strictly as replies: paths stay inside the +//! root, lists are bounded, and unknown fields fail, because a provider runs +//! the VCS client with them. + +use super::model::{self, Capabilities, MAX_HISTORY_PAGE, MAX_MESSAGE_BYTES}; +use crate::plugins::declarative::Validation; +use crate::protocol::{CoreError, ErrorCode}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; + +/// Most paths one mutation may name. +const MAX_PATHS: usize = 10_000; +/// History page size when the host does not ask for one. +const DEFAULT_HISTORY_LIMIT: usize = 100; + +/// One operation of protocol version 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Operation { + Status, + BaseContent, + History, + FileHistory, + Annotate, + Checkin, + Rollback, + Update, + FileOperation, + Resolve, + Cleanup, +} + +/// Every operation, for method checks and contract tests. +pub(crate) const OPERATIONS: [Operation; 11] = [ + Operation::Status, + Operation::BaseContent, + Operation::History, + Operation::FileHistory, + Operation::Annotate, + Operation::Checkin, + Operation::Rollback, + Operation::Update, + Operation::FileOperation, + Operation::Resolve, + Operation::Cleanup, +]; + +impl Operation { + /// Parses the operation name a host sends, which is also the last + /// segment of its method. + pub(crate) fn parse(name: &str) -> Option { + OPERATIONS + .into_iter() + .find(|operation| operation.name() == name) + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Status => "status", + Self::BaseContent => "baseContent", + Self::History => "history", + Self::FileHistory => "fileHistory", + Self::Annotate => "annotate", + Self::Checkin => "checkin", + Self::Rollback => "rollback", + Self::Update => "update", + Self::FileOperation => "fileOperation", + Self::Resolve => "resolve", + Self::Cleanup => "cleanup", + } + } + + /// JSON-RPC method the host writes to the provider. + pub(crate) fn method(self) -> &'static str { + match self { + Self::Status => "lithe/vcs/status", + Self::BaseContent => "lithe/vcs/baseContent", + Self::History => "lithe/vcs/history", + Self::FileHistory => "lithe/vcs/fileHistory", + Self::Annotate => "lithe/vcs/annotate", + Self::Checkin => "lithe/vcs/checkin", + Self::Rollback => "lithe/vcs/rollback", + Self::Update => "lithe/vcs/update", + Self::FileOperation => "lithe/vcs/fileOperation", + Self::Resolve => "lithe/vcs/resolve", + Self::Cleanup => "lithe/vcs/cleanup", + } + } + + /// Capability the provider must have negotiated for this operation. + pub(crate) fn capability(self) -> &'static str { + match self { + Self::FileOperation => "fileOperations", + Self::Resolve => "conflicts", + other => other.name(), + } + } +} + +/// Whether `method` belongs to the protocol; its replies are wrapped like +/// command results so no LSP normalizer reinterprets them. +pub(crate) fn is_vcs_method(method: &str) -> bool { + OPERATIONS + .into_iter() + .any(|operation| operation.method() == method) +} + +fn invalid_request(message: impl Into) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn arguments(operation: Operation, value: Value) -> Result { + // An operation without arguments may omit them entirely. + let value = if value.is_null() { json!({}) } else { value }; + serde_json::from_value(value).map_err(|error| { + invalid_request(format!("Invalid {} arguments", operation.name())) + .with_details(error.to_string()) + }) +} + +fn root_path(path: &str) -> Result { + if model::is_root_relative(path) { + Ok(path.to_string()) + } else { + Err(invalid_request("A VCS path must be relative to its root.").with_details(path)) + } +} + +/// Root-relative paths, sorted and unique; at least one, at most +/// [`MAX_PATHS`]. +fn path_list(paths: &[String]) -> Result, CoreError> { + if paths.is_empty() || paths.len() > MAX_PATHS { + return Err(invalid_request(format!( + "An operation names 1-{MAX_PATHS} paths." + ))); + } + let unique: BTreeSet = paths + .iter() + .map(|path| root_path(path)) + .collect::>()?; + Ok(unique.into_iter().collect()) +} + +fn absolute_path(value: &str, what: &str) -> Result { + if Path::new(value).is_absolute() && !value.contains('\0') { + Ok(value.to_string()) + } else { + Err(invalid_request(format!( + "The {what} must be an absolute path." + ))) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StatusArguments { + #[serde(default)] + include_ignored: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathArguments { + path: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathsArguments { + paths: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HistoryArguments { + #[serde(default)] + path: Option, + /// Opaque cursor from the previous page; absent for the first page. + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CheckinArguments { + paths: Vec, + message: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UpdateArguments { + /// Revision ID to update to, as the provider reported it; absent for + /// the newest revision. + #[serde(default)] + revision: Option, +} + +/// A change made in the project tree that the VCS must record. `move` +/// carries both ends so the VCS keeps the file's history. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum FileOperationArguments { + Add { paths: Vec }, + Delete { paths: Vec }, + Move { from: String, to: String }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoArguments {} + +/// The params of one request, without `root` and `tool`. +fn operation_params(operation: Operation, value: Value) -> Result { + Ok(match operation { + Operation::Status => { + let arguments: StatusArguments = arguments(operation, value)?; + json!({ "includeIgnored": arguments.include_ignored }) + } + Operation::BaseContent | Operation::Annotate => { + let arguments: PathArguments = arguments(operation, value)?; + json!({ "path": root_path(&arguments.path)? }) + } + Operation::History | Operation::FileHistory => { + let arguments: HistoryArguments = arguments(operation, value)?; + let limit = arguments.limit.unwrap_or(DEFAULT_HISTORY_LIMIT); + if limit == 0 || limit > MAX_HISTORY_PAGE { + return Err(invalid_request(format!( + "A history page has 1-{MAX_HISTORY_PAGE} entries." + ))); + } + if let Some(cursor) = &arguments.cursor { + if cursor.is_empty() || cursor.len() > 256 || cursor.chars().any(char::is_control) { + return Err(invalid_request("The history cursor is malformed.")); + } + } + let mut params = json!({ "cursor": arguments.cursor, "limit": limit }); + match (operation, arguments.path) { + (Operation::FileHistory, Some(path)) => params["path"] = json!(root_path(&path)?), + (Operation::History, None) => {} + _ => { + return Err(invalid_request( + "fileHistory needs a path; history of the whole root takes none.", + )) + } + } + params + } + Operation::Checkin => { + let arguments: CheckinArguments = arguments(operation, value)?; + if arguments.message.trim().is_empty() + || arguments.message.len() > MAX_MESSAGE_BYTES + || arguments.message.contains('\0') + { + return Err(invalid_request( + "A check-in message is non-empty text of at most 1 MiB.", + )); + } + json!({ "paths": path_list(&arguments.paths)?, "message": arguments.message }) + } + Operation::Rollback | Operation::Resolve => { + let arguments: PathsArguments = arguments(operation, value)?; + json!({ "paths": path_list(&arguments.paths)? }) + } + Operation::Update => { + let arguments: UpdateArguments = arguments(operation, value)?; + if let Some(revision) = &arguments.revision { + if revision.is_empty() + || revision.len() > 256 + || revision.chars().any(char::is_control) + { + return Err(invalid_request("The revision ID is malformed.")); + } + } + json!({ "revision": arguments.revision }) + } + Operation::FileOperation => match arguments(operation, value)? { + FileOperationArguments::Add { paths } => { + json!({ "kind": "add", "paths": path_list(&paths)? }) + } + FileOperationArguments::Delete { paths } => { + json!({ "kind": "delete", "paths": path_list(&paths)? }) + } + FileOperationArguments::Move { from, to } => { + let (from, to) = (root_path(&from)?, root_path(&to)?); + if from == to { + return Err(invalid_request("A move needs two different paths.")); + } + json!({ "kind": "move", "from": from, "to": to }) + } + }, + Operation::Cleanup => { + let _: NoArguments = arguments(operation, value)?; + json!({}) + } + }) +} + +/// A request ready to be written to a provider session. +#[derive(Debug)] +pub(crate) struct PlannedRequest { + pub(crate) method: &'static str, + pub(crate) params: Value, +} + +/// Plans one request. `root` is the native path of the working-copy root and +/// `tool` the client executable the host resolved for the provider; both are +/// sent with every request so the provider never searches for either. +pub(crate) fn plan( + operation: Operation, + root: &str, + tool: Option<&str>, + arguments: Value, + capabilities: &Capabilities, +) -> Result { + if !capabilities.contains_key(operation.capability()) { + return Err(CoreError::new( + ErrorCode::NotSupported, + format!( + "The VCS provider did not negotiate {}.", + operation.capability() + ), + ) + .with_details("vcs.capability.unsupported")); + } + let mut params = operation_params(operation, arguments)?; + params["root"] = json!({ "path": absolute_path(root, "VCS root")? }); + params["tool"] = match tool { + Some(tool) => json!({ "executable": absolute_path(tool, "VCS tool")? }), + None => Value::Null, + }; + Ok(PlannedRequest { + method: operation.method(), + params, + }) +} + +/// Decodes the reply to `operation`. `request` is the arguments the host +/// sent, which bound what a valid reply may contain. +pub(crate) fn decode( + operation: Operation, + request: &Value, + reply: &Value, + capabilities: &Capabilities, +) -> Validation { + match operation { + Operation::Status => model::decode_status(reply, capabilities), + Operation::BaseContent => model::decode_base_content(reply), + Operation::History | Operation::FileHistory => { + let limit = request + .get("limit") + .and_then(Value::as_u64) + .and_then(|limit| usize::try_from(limit).ok()) + .unwrap_or(DEFAULT_HISTORY_LIMIT) + .min(MAX_HISTORY_PAGE); + model::decode_history(reply, limit) + } + Operation::Annotate => model::decode_annotation(reply), + Operation::Checkin => model::decode_checkin(reply), + Operation::Update => model::decode_update(reply), + Operation::Rollback + | Operation::FileOperation + | Operation::Resolve + | Operation::Cleanup => model::decode_empty(reply), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_capabilities() -> Capabilities { + model::CAPABILITIES + .iter() + .map(|(id, _)| (id.to_string(), 1)) + .collect() + } + + fn root() -> String { + std::env::temp_dir() + .join("lithe-vcs-root") + .to_string_lossy() + .into_owned() + } + + #[test] + fn every_operation_round_trips_its_name_and_uses_a_contract_capability() { + for operation in OPERATIONS { + assert_eq!(Operation::parse(operation.name()), Some(operation)); + assert!(is_vcs_method(operation.method())); + assert!( + model::is_capability(operation.capability()), + "{operation:?}" + ); + } + assert!(!is_vcs_method("workspace/executeCommand")); + assert_eq!(Operation::parse("push"), None); + } + + #[test] + fn a_plan_carries_root_tool_and_checked_arguments() { + let tool = std::env::temp_dir() + .join("svn") + .to_string_lossy() + .into_owned(); + let planned = plan( + Operation::Checkin, + &root(), + Some(&tool), + json!({ "paths": ["b", "a", "b"], "message": "Fix" }), + &all_capabilities(), + ) + .expect("planned"); + assert_eq!(planned.method, "lithe/vcs/checkin"); + assert_eq!(planned.params["paths"], json!(["a", "b"])); + assert_eq!(planned.params["root"]["path"], json!(root())); + assert_eq!(planned.params["tool"]["executable"], json!(tool)); + } + + #[test] + fn a_plan_rejects_unsafe_or_unknown_arguments() { + let capabilities = all_capabilities(); + for (operation, arguments) in [ + (Operation::Rollback, json!({ "paths": ["../x"] })), + (Operation::Rollback, json!({ "paths": [] })), + ( + Operation::Checkin, + json!({ "paths": ["a"], "message": " " }), + ), + (Operation::Status, json!({ "recursive": false })), + (Operation::History, json!({ "path": "a" })), + (Operation::FileHistory, json!({})), + (Operation::History, json!({ "limit": 0 })), + ( + Operation::FileOperation, + json!({ "kind": "move", "from": "a", "to": "a" }), + ), + (Operation::Cleanup, json!({ "force": true })), + ] { + let error = plan(operation, &root(), None, arguments.clone(), &capabilities) + .expect_err("rejected"); + assert!( + matches!(error.code, ErrorCode::InvalidRequest), + "{operation:?} {arguments}" + ); + } + assert!(plan( + Operation::Status, + "relative", + None, + Value::Null, + &capabilities + ) + .is_err()); + } + + #[test] + fn an_operation_needs_its_negotiated_capability() { + let mut capabilities = all_capabilities(); + capabilities.remove("conflicts"); + let error = plan( + Operation::Resolve, + &root(), + None, + json!({ "paths": ["a"] }), + &capabilities, + ) + .expect_err("unsupported"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + } + + #[test] + fn history_replies_are_bounded_by_the_requested_limit() { + let reply = json!({ "entries": [ + { "revision": { "id": "2" } }, { "revision": { "id": "1" } } + ] }); + assert!(decode( + Operation::History, + &json!({ "limit": 2 }), + &reply, + &all_capabilities() + ) + .is_ok()); + assert!(decode( + Operation::History, + &json!({ "limit": 1 }), + &reply, + &all_capabilities() + ) + .is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/roots.rs b/rust/lithe-core/src/vcs/roots.rs new file mode 100644 index 0000000000..51a7e64d02 --- /dev/null +++ b/rust/lithe-core/src/vcs/roots.rs @@ -0,0 +1,376 @@ +//! Which VCS owns which directory of a workspace. +//! +//! Roots are found from static markers only, so opening a workspace never +//! starts a provider. One bounded walk serves every provider: it records the +//! markers directly inside each directory, never descends into a VCS's own +//! metadata directory or through a symbolic link, and stops at a depth and +//! directory budget. When the workspace root itself carries no marker, its +//! ancestors are checked too, because a Subversion working copy has a single +//! `.svn` at its top and a workspace may be opened below it. +//! +//! A file belongs to the deepest root that contains it. A directory with +//! markers of several providers is ambiguous and owns nothing until the +//! user maps it to one of them; Core never guesses. + +use super::manifest::{MarkerKind, VcsMarker}; +use super::model::is_root_relative; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs::{self, FileType}; +use std::path::{Path, PathBuf}; + +/// Deepest directory, below the workspace root, whose markers are read. +const MAX_DEPTH: usize = 8; +/// Most directories one detection reads before reporting a partial result. +const MAX_DIRECTORIES: usize = 20_000; + +/// Directories that hold generated or downloaded files and never a working +/// copy worth tracking, and whose size would dominate the walk. Lithe's own +/// worktrees are pruned so they are not listed as nested repositories. +const PRUNED: &[&str] = &[ + ".build", + ".cargo", + ".dart_tool", + ".gradle", + ".idea", + ".lithe", + ".mypy_cache", + ".next", + ".nuxt", + ".pytest_cache", + ".stack-work", + ".svelte-kit", + ".swiftpm", + ".terraform", + ".tox", + ".venv", + ".vscode", + ".worktree", + ".worktrees", + "DerivedData", + "Pods", + "__pycache__", + "bower_components", + "node_modules", + "target", +]; + +/// The markers of one VCS, as detection sees them. +pub(crate) struct MarkerSet<'a> { + pub(crate) vcs: &'a str, + pub(crate) markers: &'a [VcsMarker], +} + +/// A detected root. `id` is workspace-relative with `/` separators: `""` is +/// the workspace root and `..` segments name an enclosing directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Root { + pub(crate) id: String, + pub(crate) vcs: String, + pub(crate) path: PathBuf, + /// Whether the user's mapping, not a single marker, chose the VCS. + pub(crate) mapped: bool, +} + +/// A directory with markers of several VCSs and no mapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Ambiguous { + pub(crate) id: String, + pub(crate) vcs: Vec, +} + +/// The user's choice for an ambiguous directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Mapping { + pub(crate) id: String, + pub(crate) vcs: String, +} + +#[derive(Debug, Default)] +pub(crate) struct Detection { + pub(crate) roots: Vec, + pub(crate) ambiguous: Vec, + /// Mappings that name no ambiguous directory, or a VCS whose markers + /// are not there. Hosts may offer to forget them. + pub(crate) unused_mappings: Vec, + /// Whether the directory budget cut the walk short, so a root may be + /// missing. Roots deeper than [`MAX_DEPTH`] are never looked for. + pub(crate) truncated: bool, +} + +/// Whether `id` names a directory enclosing the workspace: only `..` +/// segments. A directory inside the workspace may still be named `..repo`. +fn is_enclosing(id: &str) -> bool { + !id.is_empty() && id.split('/').all(|segment| segment == "..") +} + +/// Sort key that puts enclosing roots first, then inner roots by depth. +pub(crate) fn depth(id: &str) -> i64 { + if id.is_empty() { + return 0; + } + let segments = id.split('/').count() as i64; + if is_enclosing(id) { + -segments + } else { + segments + } +} + +/// Whether `id` has the shape of a root or ambiguous-directory ID: `""`, +/// only `..` segments, or a path inside the workspace. +pub(crate) fn is_directory_id(id: &str) -> bool { + id.is_empty() || is_enclosing(id) || is_root_relative(id) +} + +/// Whether the root or ambiguous directory `id` contains the +/// workspace-relative `path`. +fn contains(id: &str, path: &str) -> bool { + id.is_empty() + || is_enclosing(id) + || path == id + || path + .strip_prefix(id) + .is_some_and(|rest| rest.starts_with('/')) +} + +/// The root ID that owns a workspace-relative path, or `None` when the path +/// is outside every root or inside an unmapped ambiguous directory. +pub(crate) fn owner<'a>(roots: &'a [String], ambiguous: &[String], path: &str) -> Option<&'a str> { + let deepest_root = roots + .iter() + .filter(|root| contains(root, path)) + .max_by_key(|root| depth(root)); + let deepest_ambiguous = ambiguous + .iter() + .filter(|directory| contains(directory, path)) + .map(|directory| depth(directory)) + .max(); + match (deepest_root, deepest_ambiguous) { + (Some(root), Some(ambiguous)) if ambiguous > depth(root) => None, + (root, _) => root.map(String::as_str), + } +} + +/// Resolves a directory entry's kind, following a link only for the +/// marker check itself (a `.git` that links elsewhere still marks a root). +fn marker_kind(path: &Path, file_type: FileType) -> Option { + let file_type = if file_type.is_symlink() { + fs::metadata(path).ok()?.file_type() + } else { + file_type + }; + if file_type.is_dir() { + Some(MarkerKind::Directory) + } else if file_type.is_file() { + Some(MarkerKind::File) + } else { + None + } +} + +fn matches(sets: &[MarkerSet<'_>], name: &str, kind: MarkerKind) -> Vec { + sets.iter() + .filter(|set| { + set.markers + .iter() + .any(|marker| marker.name == name && marker.kind == kind) + }) + .map(|set| set.vcs.to_string()) + .collect() +} + +/// VCSs whose markers sit directly in `directory`, checked by name. +fn markers_at(directory: &Path, sets: &[MarkerSet<'_>]) -> BTreeSet { + let names: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter().map(|marker| marker.name.as_str())) + .collect(); + let mut found = BTreeSet::new(); + for name in names { + let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if let Some(kind) = marker_kind(&path, metadata.file_type()) { + found.extend(matches(sets, name, kind)); + } + } + found +} + +/// Walks the workspace and its ancestors. `workspace` must be absolute. +pub(crate) fn detect(workspace: &Path, sets: &[MarkerSet<'_>], mappings: &[Mapping]) -> Detection { + let metadata_directories: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter()) + .filter(|marker| marker.kind == MarkerKind::Directory) + .map(|marker| marker.name.as_str()) + .collect(); + let mut found: BTreeMap)> = BTreeMap::new(); + let mut truncated = false; + let mut visited = 0usize; + let mut queue = VecDeque::from([(workspace.to_path_buf(), String::new(), 0usize)]); + // Breadth first, children sorted by name: when the budget runs out the + // shallow roots are kept, and two runs over one tree agree. + while let Some((directory, relative, level)) = queue.pop_front() { + if visited == MAX_DIRECTORIES { + truncated = true; + break; + } + visited += 1; + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + let mut entries: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_str()?.to_string(); + Some((name, entry.path(), entry.file_type().ok()?)) + }) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut here = BTreeSet::new(); + for (name, path, file_type) in entries { + if let Some(kind) = marker_kind(&path, file_type) { + here.extend(matches(sets, &name, kind)); + } + // Source trees are routinely deeper than the limit, so stopping + // there is the rule rather than a partial result. + if !file_type.is_dir() + || level == MAX_DEPTH + || metadata_directories.contains(name.as_str()) + || PRUNED.contains(&name.as_str()) + { + continue; + } + let child = if relative.is_empty() { + name + } else { + format!("{relative}/{name}") + }; + queue.push_back((path, child, level + 1)); + } + if !here.is_empty() { + found.insert(relative, (directory, here)); + } + } + + if !found.contains_key("") { + let mut id = String::from(".."); + let mut ancestor = workspace.parent(); + while let Some(directory) = ancestor { + let here = markers_at(directory, sets); + if !here.is_empty() { + found.insert(id, (directory.to_path_buf(), here)); + break; + } + id.push_str("/.."); + ancestor = directory.parent(); + } + } + + let mut detection = Detection { + truncated, + ..Detection::default() + }; + let mut used = BTreeSet::new(); + for (id, (path, vcs)) in found { + if vcs.len() == 1 { + let vcs = vcs.into_iter().next().unwrap_or_default(); + detection.roots.push(Root { + id, + vcs, + path, + mapped: false, + }); + continue; + } + match mappings + .iter() + .find(|mapping| mapping.id == id && vcs.contains(&mapping.vcs)) + { + Some(mapping) => { + used.insert((mapping.id.clone(), mapping.vcs.clone())); + detection.roots.push(Root { + id, + vcs: mapping.vcs.clone(), + path, + mapped: true, + }); + } + None => detection.ambiguous.push(Ambiguous { + id, + vcs: vcs.into_iter().collect(), + }), + } + } + detection.unused_mappings = mappings + .iter() + .filter(|mapping| !used.contains(&(mapping.id.clone(), mapping.vcs.clone()))) + .cloned() + .collect(); + detection + .unused_mappings + .sort_by(|left, right| (&left.id, &left.vcs).cmp(&(&right.id, &right.vcs))); + detection.unused_mappings.dedup(); + detection + .roots + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection + .ambiguous + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(ToString::to_string).collect() + } + + #[test] + fn the_deepest_root_owns_a_path_and_ambiguity_blocks_fallback() { + let roots = ids(&["..", "", "a/b"]); + let ambiguous = ids(&["m"]); + let owner_of = |path| owner(&roots, &ambiguous, path); + assert_eq!(owner_of("README.md"), Some("")); + assert_eq!(owner_of("a/b/c.txt"), Some("a/b")); + assert_eq!(owner_of("a/bc.txt"), Some("")); + // Files below an unmapped ambiguous directory belong to nobody, not + // to the enclosing Git root. + assert_eq!(owner_of("m/x.txt"), None); + assert_eq!(owner_of("mx.txt"), Some("")); + } + + #[test] + fn a_directory_named_like_dot_dot_is_inside_the_workspace() { + let roots = ids(&["", "..repo"]); + let owner_of = |path| owner(&roots, &[], path); + assert_eq!(owner_of("..repo/a.txt"), Some("..repo")); + assert_eq!(owner_of("README.md"), Some("")); + // Without a workspace root, `..repo` owns only its own files. + let inner = ids(&["..repo"]); + assert_eq!(owner(&inner, &[], "src/main.rs"), None); + assert_eq!(depth("..repo"), 1); + assert!(is_directory_id("..repo/x")); + } + + #[test] + fn enclosing_roots_sort_before_inner_ones() { + let mut ids = vec!["a/b", "", "../..", "a", ".."]; + ids.sort_by_key(|id| (depth(id), *id)); + assert_eq!(ids, ["../..", "..", "", "a", "a/b"]); + } + + #[test] + fn directory_ids_are_enclosing_or_inside_the_workspace() { + for id in ["", "..", "../..", "a", "a/b"] { + assert!(is_directory_id(id), "{id}"); + } + for id in ["/a", "../a", "a/..", "a//b", "./a", "a\\b"] { + assert!(!is_directory_id(id), "{id}"); + } + } +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 981f4c0e98..5405eb9845 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -197,8 +197,8 @@ contains a machine path, and the host decides where state lives. | `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | `plugins.declarative.languageServerLaunchPlan` expands a server's launch. The -host selects the process with exactly one of `languageID` (a language server) -and `buildSystemID` (a build provider), passes `{pluginDirectory, +host selects the process with exactly one of `languageID` (a language server), +`buildSystemID` (a build provider), and `vcsProviderID` (a VCS provider), passes `{pluginDirectory, stateDirectory, workspaceStateDirectory, architecture, settings?}`, and receives: @@ -213,9 +213,11 @@ receives: | `requiredPackagePaths` | Sorted package-relative paths the launch points at. The host verifies each is inside the package and not a symbolic link before starting the server. | | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | +| `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | -A language without a server, an undeclared build system, and a build system -without a provider are `plugin.launch.unavailable`. Core never creates or +A language without a server, an undeclared build system, a build system +without a provider, and an undeclared VCS provider are +`plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. @@ -368,7 +370,7 @@ files sorted by path, or an empty list when no project marker is present. | --- | --- | | `1` | Languages, `packaged` and `systemCommand` language servers, `node` and `go` runtime dependencies, run and test providers. | | `2` | `runtime` launches with a packaged or discovered runtime, host integrations, the `java` runtime, launch templates, `initializationOptions`, and plugin settings. | -| `3` | Capability versions (`requires`), and `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync. | +| `3` | Capability versions (`requires`), `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync, and [`vcsProviders`](#api-level-3-vcs-providers). | A level 2 feature in a level 1 manifest is refused with `plugin.manifest.invalid` (`plugin.runtime.unsupported` for the `java` tool), @@ -392,12 +394,13 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | --- | --- | --- | | `languages` | `languages[]` | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | -| `serverProcess` | The launch fields of language servers and of build providers with a process of their own: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | +| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, and of VCS providers: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | | `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | +| `vcsProviders` | `vcsProviders[]` | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -418,7 +421,8 @@ moving a level 2 manifest to level 3 only adds `requires`. `buildSystems[].provider` requires `buildProviders: 1`, whether it starts its own process or shares a language server. A template-only build plugin must omit `buildProviders`. Launch support alone does not implement Provider - negotiation, requests, and lifecycle. + negotiation, requests, and lifecycle. Every VCS provider starts its own + process, so `vcsProviders` always comes with `serverProcess`. - The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, and `requires` itself) change only with a new `pluginAPIVersion`. @@ -443,8 +447,10 @@ The macOS host runs levels 1 to 3 and sends its capability table, including `buildSystems: [1]`, with all three commands. It does not implement build providers and does not declare `buildProviders`. Provider plugins are therefore filtered from the market and refused by declarative validation and install -review; template-only build plugins remain available. Core supports -`buildProviders: 1` for validation and plans without a host table. Windows has +review; template-only build plugins remain available. It does not declare +`vcsProviders` either, so VCS provider plugins are filtered the same way until +the macOS host starts providers. Core supports `buildProviders: 1` and +`vcsProviders: 1` for validation and plans without a host table. Windows has no declarative plugin host and sends no `hostCapabilities`, so it offers no level 3 version. @@ -473,10 +479,11 @@ Rules for publishing across levels and capability versions: | Permission | Required when | | --- | --- | -| `languageServer.launch` | `languageServers` is not empty | +| `languageServer.launch` | `languageServers` or `vcsProviders` is not empty, or a build provider starts its own process | | `process.run` | any provider declares `run` | | `process.test` | any provider declares `test` | | `process.build` | `buildSystems` is not empty | +| `process.vcs` | `vcsProviders` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | @@ -486,6 +493,7 @@ Rules for publishing across levels and capability versions: | `onRun:` | Activate the run provider when the user runs a file of the language. | | `onTest:` | Activate the test provider when the user lists or runs tests. | | `onBuildSystem:` | Activate the build system before its first plan, model, or decode. | +| `onVcsRoot:` | Start the VCS provider once a root of its VCS is detected in the workspace. | Each provider needs its event and each event needs a provider. Enabling a plugin only permits activation; nothing starts until its event occurs. @@ -622,7 +630,8 @@ the plugin, sorted by `kind`, language, and tool: | `runtimeLanguageServer` | `languageID`, `tool`, `packagedRuntime`, `entry`, `runtimeArguments`, `arguments`, `validationArguments` | Package entry started by a runtime; `packagedRuntime` is `null` for a discovered runtime | | `buildSystem` | `buildSystemID`, `invocation`, `options`, `tasks`, `run`, `test`, `model`, `dependencies`, `hostIntegration`?, `provider`? | Every command line the build system can plan; `provider` is the shared form, which sends build requests to a language server | | `packagedBuildProvider`, `systemBuildProvider`, `runtimeBuildProvider` | the matching language-server fields with `buildSystemID`, `executesProjectCode`, `mayUseNetwork` instead of `languageID` | A build provider with a process of its own | -| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server or build provider | +| `packagedVcsProvider`, `systemVcsProvider`, `runtimeVcsProvider` | the matching language-server fields with `vcs`, `vcsTool`, `mayUseNetwork` instead of `languageID` | A VCS provider process; `vcsTool` is the declared VCS client or `null` | +| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server, build provider, or VCS provider | | `run` | `languageID`, `executable`, `arguments` | Run command and its argument template | | `test` | `languageID`, `executable`, `workspaceArguments`, `fileArguments`, `testCaseArguments` | Test command and its argument templates | @@ -965,6 +974,25 @@ digests the watched files. A selection the declaration does not allow is `plugin.build.invalidSelection`; an operation it does not declare is `plugin.build.unavailable`. +## API level 3: VCS providers + +`vcsProviders[]` declares version control systems the plugin implements, each +through its own LSP base-protocol process: the VCS ID, the static markers that +identify a root, metadata paths the host watches, the VCS client the provider +drives, and the shared [`serverProcess`](#capability-versions-level-3) launch +fields. The ID `git` is reserved for the built-in support +(`plugin.vcs.reserved`). Capabilities belong to the running process and are +negotiated in `initialize` under `experimental.litheVcs`, never declared in +the manifest. + +Detection never starts a process: `vcs.detectRoots` finds roots from the +markers, and the host starts a provider only after a root of its VCS is +found. The install review lists the provider as a `…VcsProvider` program with +its VCS client (`vcsTool`) and `mayUseNetwork`. Requests go through +`lsp.vcsRequest` by operation name and replies are normalized by +`vcs.decodeReply`. The fields, operations, reply shapes, and error codes are +defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). + ## Plugin functions A plugin backend (a language server or build provider process the plugin @@ -1169,7 +1197,7 @@ valid. ## Outcomes and error codes -`plugins.declarative.*`, `plugins.marketplace.*`, and `build.*` commands return `{"status": "ok", …}` or +`plugins.declarative.*`, `plugins.marketplace.*`, `build.*`, and `vcs.*` commands return `{"status": "ok", …}` or `{"status": "rejected", "error": {"code", "message", "field"?}}`. A malformed command payload is a Core `invalid_request` failure instead. @@ -1188,11 +1216,13 @@ command payload is a Core `invalid_request` failure instead. | `plugin.test.unsupportedProject` | Core | No project marker in the workspace | | `plugin.execution.unavailable` | Core | The plugin has no run/test provider for the language | | `plugin.integration.unsupported` | Core | A host integration the host does not implement, or one requested by a plugin outside the official namespace | -| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, or no provider process for the build system | +| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, no provider process for the build system, or no such VCS provider | | `plugin.build.unavailable` | Core | The plugin declares no such build system, or the build system does not declare the requested operation | | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | | `plugin.build.invalidOutput` | Core | Build-tool output or a report does not fit its decoder | | `plugin.launch.invalidRequest` | Core | A launch plan request has an unusable directory or an unknown architecture | +| `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | +| `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | | `plugin.index.schemaUnsupported` | Core | The index `schemaVersion` is not `1` | | `plugin.index.officialNamespace` | Core | A third-party source lists a `dev.lithe` ID | diff --git a/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json new file mode 100644 index 0000000000..b78094beac --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.vcs-provider", + "displayName": "VCS Provider Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.vcs" + ], + "activationEvents": [ + "onVcsRoot:svn" + ], + "languages": [], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { + "markers": [{ "name": ".svn", "type": "directory" }] + }, + "metadataPaths": [".svn/wc.db"], + "tool": { + "commands": ["svn"], + "validationArguments": ["--version", "--quiet"] + }, + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "provider/main.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 3571dd1a21..c24d138ffb 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -27,9 +27,10 @@ "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, - "serverProcess": { "description": "The launch fields of language servers and of build providers that start their own process.", "enum": [1] }, + "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, and of VCS providers.", "enum": [1] }, "settings": { "enum": [1] }, - "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] } + "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] }, + "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } }, "vendor": { @@ -44,11 +45,11 @@ }, "permissions": { "type": "array", - "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "ui.contribute", "ui.web"] } + "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "ui.contribute", "ui.web"] } }, "activationEvents": { "type": "array", - "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem):[a-z0-9.+_-]+$" } + "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot):[a-z0-9.+_-]+$" } }, "languages": { "type": "array", @@ -69,6 +70,12 @@ "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, + "vcsProviders": { + "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/vcsProvider" } + }, "ui": { "type": "object", "additionalProperties": false, @@ -362,7 +369,7 @@ } }, "serverProcess": { - "description": "The launch fields a language server and a pluginAPIVersion 3 build provider share; both are LSP base-protocol processes and pass the same validation.", + "description": "The launch fields a language server, a pluginAPIVersion 3 build provider, and a VCS provider share; all are LSP base-protocol processes and pass the same validation.", "type": "object", "required": ["launch"], "properties": { @@ -452,6 +459,66 @@ } ] }, + "vcsProvider": { + "description": "pluginAPIVersion 3: a VCS provider process (shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false, + "required": ["id", "displayName", "detection", "mayUseNetwork"], + "properties": { + "id": { + "description": "Stable VCS ID; git is reserved for the built-in support.", + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "not": { "const": "git" } + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 64 }, + "detection": { + "type": "object", + "additionalProperties": false, + "required": ["markers"], + "properties": { + "markers": { + "description": "Entries directly inside a root that mark it.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\]+$", "not": { "enum": [".", ".."] } }, + "type": { "enum": ["directory", "file"] } + } + } + } + } + }, + "metadataPaths": { + "description": "Root-relative paths the host watches; a change means the status may be stale.", + "type": "array", + "maxItems": 16, + "items": { "type": "string", "minLength": 1 } + }, + "tool": { + "description": "The VCS client the provider drives. The host resolves it like a systemCommand launch and passes its absolute path with every request.", + "type": "object", + "additionalProperties": false, + "required": ["commands"], + "properties": { + "commands": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "type": "string", "pattern": "^[^/\\\\\\s]+$" } + }, + "validationArguments": { "type": "array", "items": { "type": "string" } } + } + }, + "mayUseNetwork": { "type": "boolean" } + } + }, "minimumVersion": { "description": "pluginAPIVersion 2: one to three dot-separated numbers, for example 21 or 18.17.", "type": "string", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 67f88f437d..87ca9cc1ea 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -965,6 +965,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.request` | Submit a typed semantic request and return an opaque operation ID | | `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | +| `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | | `java.resolveNavigation` | Resolve one Java gutter marker to normalized parent or implementation locations | | `lsp.cancelOperation` | Cancel one pending semantic operation | @@ -1040,7 +1041,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.runPlan` | Expand a declarative plugin's run template for one workspace file | | `plugins.declarative.discoverTests` | List a declarative plugin's workspace and file test items | | `plugins.declarative.testPlan` | Expand a declarative plugin's test template for a workspace, file, or test case | -| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server or build-provider launch with host directories and architecture | +| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server, build-provider, or VCS-provider launch with host directories and architecture | | `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | @@ -1058,6 +1059,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.decodeDependencies` | Decode dependency output or a provider result into one project's bounded tree | | `build.syncFingerprint` | Digest the files a build model depends on, to tell whether it is stale | | `build.languageImport` | Map a build system's options to the Java language server's Maven or Gradle project import | +| `vcs.detectRoots` | Find the VCS roots of a workspace from static markers, with registered providers, ambiguous directories, and applied mappings | +| `vcs.ownerOf` | Assign workspace paths to the detected root that owns each | +| `vcs.decodeReply` | Validate a VCS provider's reply and return the normalized result | | `plugins.marketplace.validateIndex` | Validate a marketplace index and report each plugin's availability for one platform | | `plugins.marketplace.resolveSource` | Resolve a user-entered marketplace source to the index URL to fetch | | `plugins.marketplace.installReview` | List a package's permissions and programs, and those not yet approved | @@ -2479,6 +2483,23 @@ event carries the provider's reply unchanged as `result.value`, which the host passes to `build.decodeModel` or `build.decodeDependencies` as `result`; cancellation and late results follow `lsp.cancelOperation`. +`lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, +tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. +`operation` is a [VCS provider contract](vcs-provider-v1.md#lspvcsrequest) +operation name, never a method; Core checks `arguments`, adds the absolute +`root` and `tool` paths, and writes the operation's `lithe/vcs/*` request. +Only plugin-owned sessions offer `capabilities.experimental.litheVcs: +{ versions: [1] }`; a session whose process did not answer with a VCS ID and +every required capability, or answered another VCS than `vcs`, refuses the +request with `not_supported` and details `vcs.provider.unavailable`, and an +operation whose capability was not negotiated is `not_supported` with details +`vcs.capability.unsupported`. A successful negotiation emits a `vcsProvider` +runtime event whose `result` is `{ vcs, capabilities }`. The +`requestCompleted` event carries the provider's reply as `result.value`, which +the host passes to `vcs.decodeReply`; a failed request carries a `vcs.*` +`error.code` (the provider's own code only when the contract lists it). +Timeout, cancellation, and late results follow `lsp.buildRequest`. + A plugin-owned session started with `workspaceConfiguration` answers each `workspace/configuration` item from it by `section` (an exact key wins over a dotted path; no section returns the whole object, a missing one `null`) and @@ -3265,6 +3286,26 @@ inside `workspaceRoot` (and the given `outputFile`); none of the commands writes anything or starts a process. `shared/contracts/plugins/v1/fixtures/build-plans.json` holds the golden plans and decoded outputs. +### VCS providers + +The `vcs.*` commands implement API level 3 `vcsProviders` of +[`plugins/v1`](plugins/v1/README.md) and the +[VCS provider contract](vcs-provider-v1.md). Results use the +`plugins.declarative.*` envelope; a malformed payload, a relative +`workspaceRoot`, or an unknown operation is an `invalid_request` failure. + +| Command | Request | Result | +| --- | --- | --- | +| `vcs.detectRoots` | `{ workspaceRoot, plugins: [{ manifest }], mappings?: [{ id, vcs }] }` | `detection`: `{ providers, roots: [{ id, vcs, path, mapped }], ambiguous: [{ id, vcs }], unusedMappings, truncated, rejected: [{ index, error }], conflicts: [{ pluginID, vcs, by }] }` | +| `vcs.ownerOf` | `{ roots: [id], ambiguous?: [id], paths: [path] }` | `owners`: `[{ path, root }]` in request order; `root` is `null` outside every root or inside an unmapped ambiguous directory | +| `vcs.decodeReply` | `{ operation, capabilities, request?, reply }` | `result`: the normalized reply, or `plugin.vcs.invalidOutput` | + +`vcs.detectRoots` reads directory entries below `workspaceRoot` (at most eight +levels and 20,000 directories) and the marker names in its ancestors; it never +reads file contents, writes anything, or starts a process. The other two +commands touch no file. `shared/fixtures/vcs/model-v1.json` holds the +detection cases, decoded snapshots, and reply validation cases. + ### Git patch discovery and rebase amendment preconditions `git.patchExport` accepts optional `metadataOnly` (default false). In metadata diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md index 3d80008eb8..4cd2ec5fea 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/shared/contracts/vcs-provider-v1.md @@ -1,61 +1,251 @@ -# VCS provider contract v1 (draft) +# VCS provider contract v1 -> **Status: draft, not implemented.** No Core command, plugin manifest field, -> host capability, or provider method below is accepted by any Lithe build yet. -> Names and fields may change until phase P1 lands. The decision record is +> **Status: Core implemented (P1); no host uses it yet.** Core accepts the +> `vcsProviders` manifest capability and the commands below. The macOS and +> Windows hosts do not start providers yet, so no build shows a plugin VCS. +> The decision record is > [Unified VCS model and providers](../../.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); -> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json). +> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json) +> and [`plugins/v1/fixtures/vcs-provider.plugin.json`](plugins/v1/fixtures/vcs-provider.plugin.json). This contract defines one VCS-neutral model for the features every version control system has: roots, file status, base content, history, annotation, -check-in, rollback, and update. A **provider** implements it for one VCS. Git is -a built-in provider inside Core that adapts the existing `git.*` operations; -other systems (Subversion first) are plugin providers that run as LSP -base-protocol processes, the same process kind as -[build providers](plugins/v1/README.md#build-providers). +check-in, rollback, and update. A **provider** implements it for one VCS as a +plugin process speaking the LSP base protocol, the same process kind as +[build providers](plugins/v1/README.md#build-providers). Subversion is the +first provider. -`git.*` commands, fields, and fixtures are unchanged by this contract. Features -that only exist in one VCS (Git log graph, rebase, worktrees, stash, push -dialog; Subversion locks UI, properties editor, relocate) stay in that -provider's own module or plugin and are not modeled here. +Git is not part of this layer yet. Core recognizes `.git` markers so a nested +working copy is assigned to the right root, and the `git.*` commands keep +serving Git roots unchanged. Features that only exist in one VCS (Git log +graph, rebase, worktrees, stash; Subversion locks, properties, relocate) are +not modeled here. ## Shared rules -- UTF-8 JSON; deterministic ordering of every list. -- Paths inside a root are root-relative with `/` separators. A root's own - identity is a workspace-relative `id` (`..` allowed for enclosing roots); its - native `path` is a platform binding, never a portable identity. -- Line numbers are one-based; missing values are `null`. +- UTF-8 JSON; every list has a deterministic order. +- Paths inside a root are root-relative with `/` separators and no empty, + `.`, or `..` segment. A root's identity is a workspace-relative `id` (`""` + for the workspace, `..` segments for an enclosing directory); its native + `path` is a platform binding, never a portable identity. +- Missing values are `null`. - Hosts never parse or order revision IDs; only the provider compares them. -- Remote locations (Subversion URLs, Git remotes) are display data with user - info, query, and fragment removed before they leave Core. +- Remote locations are display data; Core removes user info, query, and + fragment before they leave it. - Secrets never appear in arguments, results, console events, or logs. -## Model +## Manifest (plugin API level 3) -### Provider descriptor +```json +{ + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "permissions": ["languageServer.launch", "process.vcs"], + "activationEvents": ["onVcsRoot:svn"], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { "markers": [{ "name": ".svn", "type": "directory" }] }, + "metadataPaths": [".svn/wc.db"], + "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, + "launch": { "kind": "runtime", "tool": "node", "minimumVersion": "20.3", "entry": "provider/main.mjs" }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `id` | `[a-z][a-z0-9-]{0,31}`; unique in the plugin. `git` is reserved (`plugin.vcs.reserved`). | +| `displayName` | One line of 1-64 characters. | +| `detection.markers` | 1-8 entries directly inside a root; `type` is `directory` or `file`. | +| `metadataPaths` | Up to 16 root-relative paths the host watches to refresh status. | +| `tool` | Optional. Bare command names the host resolves like a `systemCommand` launch, and an optional probe. | +| `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | The shared server-process fields. | +| `mayUseNetwork` | Shown in the install review. | + +A plugin declares at most 8 providers. A non-empty `vcsProviders` requires the +`languageServer.launch` and `process.vcs` permissions, the activation event +`onVcsRoot:` for every provider, and `requires.vcsProviders` and +`requires.serverProcess`. Capabilities are not declared in the manifest: they +belong to the running process and are negotiated. + +The install review lists one program per provider, of kind +`packagedVcsProvider`, `systemVcsProvider`, or `runtimeVcsProvider`, with +`vcs`, `vcsTool` (the declared `tool`), and `mayUseNetwork`, plus the +runtime it needs. Changing the tool or the launch is a new program the user +approves again. + +`plugins.declarative.languageServerLaunchPlan` with `vcsProviderID` plans +the process and adds `vcsProvider`, the descriptor below. + +## Provider descriptor + +```json +{ + "vcs": "svn", + "displayName": "Subversion", + "origin": { "kind": "plugin", "pluginID": "dev.lithe.plugin.svn-support" }, + "markers": [{ "name": ".svn", "type": "directory" }], + "metadataPaths": [".svn/wc.db"], + "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, + "mayUseNetwork": true +} +``` + +The built-in Git entry is `{ vcs: "git", displayName: "Git", origin: { kind: +"builtIn" }, markers }` with the `.git` directory and file markers. + +## Core commands + +| Command | Purpose | +| --- | --- | +| `vcs.detectRoots` | Providers, roots, ambiguous directories, and applied mappings of a workspace | +| `vcs.ownerOf` | The root that owns each workspace path | +| `lsp.vcsRequest` | Sends one operation to a negotiated provider session | +| `vcs.decodeReply` | Validates a provider's reply and returns the normalized result | + +### `vcs.detectRoots` + +```json +{ + "workspaceRoot": "/abs/workspace", + "plugins": [{ "manifest": { } }], + "mappings": [{ "id": "mixed", "vcs": "svn" }] +} +``` + +Core validates every manifest again; a rejected one is listed as +`rejected: [{ index, error }]` and the rest still count. When two plugins claim +one VCS, an official plugin wins, then the lower plugin ID; the loser is listed +in `conflicts: [{ pluginID, vcs, by }]`. A relative `workspaceRoot` fails the +call. + +```json +{ + "status": "ok", + "detection": { + "providers": [ ], + "roots": [{ "id": "services/legacy", "vcs": "svn", "path": "/abs/workspace/services/legacy", "mapped": false }], + "ambiguous": [{ "id": "mixed", "vcs": ["git", "svn"] }], + "unusedMappings": [], + "truncated": false, + "rejected": [], + "conflicts": [] + } +} +``` + +Roots come from static markers; detection never starts a process. One +breadth-first walk reads the markers directly inside each directory down to +eight levels below the workspace, at most 20,000 directories. It never enters a +marker directory (`.git`, `.svn`), a symbolic link, or a dependency or build +directory such as `node_modules`, `target`, or `.worktrees`. When the workspace +itself has no marker, its ancestors are checked, because a Subversion working +copy has one `.svn` at its top; the enclosing root's `id` is `..`, `../..`, …. +`truncated` is `true` only when the directory budget ran out, so a root may be +missing. + +A directory with markers of several VCSs is ambiguous until a mapping names +one of them; Core never guesses. Mappings that name no ambiguous directory, or +a VCS whose markers are not there, are returned in `unusedMappings`. Roots and +ambiguous directories are ordered by depth (enclosing first), then `id`. + +### `vcs.ownerOf` + +```json +{ "roots": ["", "services/legacy"], "ambiguous": ["mixed"], "paths": ["README.md", "mixed/a.txt"] } +``` + +returns `{ status: "ok", owners: [{ path, root }] }` in request order. A path +belongs to the deepest root that contains it; inside an unmapped ambiguous +directory it belongs to no root (`null`), not to an enclosing one. At most +100,000 paths per call. + +### `lsp.vcsRequest` ```json { - "vcs": "git", - "displayName": "Git", - "origin": { "kind": "builtIn" }, - "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "push": 1, "staging": 1 } + "sessionID": "…", + "operationID": "…", + "vcs": "svn", + "operation": "checkin", + "root": "/abs/workspace/services/legacy", + "tool": "/opt/homebrew/bin/svn", + "arguments": { "paths": ["src/Main.java"], "message": "Fix" }, + "timeoutMilliseconds": 600000 } ``` -`vcs` is a stable lowercase ID (`git`, `svn`, `hg`, …). Plugin providers report -`origin: { "kind": "plugin", "pluginID": "" }`. Two installed providers may -not claim the same `vcs`; the second is reported as a conflict and not -registered. +The host names an operation, never a method, so a session cannot be used as a +generic JSON-RPC tunnel. Core refuses the request before writing it when the +session did not negotiate a provider for `vcs` +(`notSupported`, details `vcs.provider.unavailable`), when the provider did not +negotiate the operation's capability (`notSupported`, details +`vcs.capability.unsupported`), or when the arguments are malformed +(`invalidRequest`). `timeoutMilliseconds` is capped at one hour; the session's +request timeout applies when it is absent. The result arrives as a +`requestCompleted` event with `result: { value: }`; a failure carries +one of the error codes below. + +| Operation | Method | Capability | Arguments | +| --- | --- | --- | --- | +| `status` | `lithe/vcs/status` | `status` | `{ includeIgnored? }` | +| `baseContent` | `lithe/vcs/baseContent` | `baseContent` | `{ path }` | +| `history` | `lithe/vcs/history` | `history` | `{ cursor?, limit? }` | +| `fileHistory` | `lithe/vcs/fileHistory` | `fileHistory` | `{ path, cursor?, limit? }` | +| `annotate` | `lithe/vcs/annotate` | `annotate` | `{ path }` | +| `checkin` | `lithe/vcs/checkin` | `checkin` | `{ paths, message }` | +| `rollback` | `lithe/vcs/rollback` | `rollback` | `{ paths }` | +| `update` | `lithe/vcs/update` | `update` | `{ revision? }` | +| `fileOperation` | `lithe/vcs/fileOperation` | `fileOperations` | `{ kind: "add" \| "delete", paths }` or `{ kind: "move", from, to }` | +| `resolve` | `lithe/vcs/resolve` | `conflicts` | `{ paths }` | +| `cleanup` | `lithe/vcs/cleanup` | `cleanup` | `{}` | + +`paths` holds 1-10,000 root-relative paths; Core sorts and deduplicates them. +A history page has 1-500 entries (100 by default). A check-in message is +non-empty text of at most 1 MiB. Unknown argument fields fail. + +Core adds `root: { path }` and `tool: { executable } | null` to the params. +The provider receives, for example: + +```json +{ "paths": ["src/Main.java"], "message": "Fix", "root": { "path": "/abs/…/legacy" }, "tool": { "executable": "/opt/homebrew/bin/svn" } } +``` + +### `vcs.decodeReply` + +```json +{ "operation": "status", "capabilities": { "status": 1, "…": 1 }, "request": { }, "reply": { } } +``` + +`capabilities` is what the session reported in its `vcsProvider` event, +`request` the arguments the host sent, and `reply` the `value` of the +completed request. The result is `{ status: "ok", result }` or +`{ status: "rejected", error: { code: "plugin.vcs.invalidOutput", … } }`. -`capabilities` maps capability IDs to the negotiated version. A missing key -means unsupported; the host hides the entry point. UI behavior is chosen from -capabilities only, never from `vcs`. +## Negotiation + +The host offers `capabilities.experimental.litheVcs: { "versions": [1] }` in +`initialize`, to plugin-owned sessions only. The provider answers: + +```json +{ "litheVcs": { "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 } } } +``` + +A session is a provider only when the answer names an offered version, a valid +VCS ID, and every required capability. Unknown capabilities, and known ones at +another version, are ignored. Core then publishes a runtime event of kind +`vcsProvider` with `result: { vcs, capabilities }`; the host compares `vcs` +with the manifest entry it launched and stops a session that answered another +one. A session without a valid answer receives no `lithe/vcs/*` request. | Capability | Required | Meaning | | --- | --- | --- | -| `status` | yes | File status for a root | +| `status` | yes | File status of a root | | `baseContent` | yes | Content of a file at its base revision (Diff, gutter) | | `checkin` | yes | Record selected changes as a new revision | | `rollback` | yes | Discard local changes of selected files | @@ -65,26 +255,17 @@ capabilities only, never from `vcs`. | `update` | no | Bring remote revisions into the working copy | | `push` | no | Check-in is local; publishing is a separate step | | `staging` | no | Changes carry a staged/unstaged dimension | -| `fileOperations` | no | Add/move/delete must go through the VCS to be tracked | +| `fileOperations` | no | Add, move, and delete must go through the VCS to be tracked | | `conflicts` | no | Report and resolve conflicted files | -| `ignore` | no | Add paths to the VCS ignore list | -| `lock` | no | Lock/unlock files and report lock owners | | `cleanup` | no | Recover an interrupted working copy | -A provider without `push` commits directly to the server; the host labels the -action accordingly and does not offer "Commit and Push". +UI behavior is chosen from capabilities only, never from `vcs`. A provider +without `push` commits directly to the server; the host labels the action +accordingly and does not offer "Commit and Push". Version 1 has no operation +for `push`: a provider that negotiates it tells the host the VCS has a +publishing step, which a later version will model. -### Root - -```json -{ "id": "services/legacy", "vcs": "svn", "path": "", "location": "https://svn.example.invalid/repo/trunk" } -``` - -Core detects roots from static markers without starting a provider. A file -belongs to the deepest root that contains it. A directory that matches markers -of two providers is reported as `ambiguous` and stays unassigned until the user -maps it; Core never guesses. Roots are ordered by workspace containment, depth, -then `id`. +## Replies ### Revision @@ -92,17 +273,17 @@ then `id`. { "id": "1234", "display": "r1234", "kind": "number" } ``` -`kind` is `hash`, `number`, or `opaque` and only affects presentation (for -example monospace hashes). `id` is passed back to the provider unchanged. +`kind` is `hash`, `number`, or `opaque` (the default) and only affects +presentation. `display` defaults to `id`. `id` is passed back to the provider +unchanged. -### Status snapshot +### `status` ```json { - "root": "services/legacy", "head": { "revision": { "id": "1234", "display": "r1234", "kind": "number" }, "branch": null, "location": "https://svn.example.invalid/repo/trunk" }, "changes": [ - { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["propertiesModified"] } + { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } ], "truncated": false } @@ -110,122 +291,71 @@ example monospace hashes). `id` is passed back to the provider unchanged. `status` is one of `modified`, `added`, `deleted`, `missing` (removed from disk without telling the VCS), `renamed`, `copied`, `replaced`, `typeChanged`, -`unversioned`, `ignored`, `conflicted`, `switched`. A plugin reply with any -other value fails with `plugin.vcs.invalidOutput`. +`unversioned`, `ignored`, `conflicted`, `switched`; any other value is invalid. +Only `renamed` and `copied` may name an `oldPath`. -`flags` refine a status: `propertiesModified`, `locked`, `lockedByOther`, -`treeConflict`, `external`, `submodule`. Core drops unknown flags so a newer -provider can add optional data within version 1. +`flags` refine a status: `external`, `locked`, `lockedByOther`, +`propertiesModified`, `submodule`, `treeConflict`. Core drops unknown flags so +a newer provider can add optional data within version 1, and sorts the rest. -Providers with `staging` add `staging: "staged" | "unstaged" | "partial"` to each -change. Changelist membership is host state and never part of a provider -reply. +A provider that negotiated `staging` gives every change +`staging: "staged" | "unstaged" | "partial"`; any other provider gives none. +Changelist membership is host state and never part of a reply. -`changes` is ordered by `path`; Core sorts plugin replies, so a provider need -not. Core caps a snapshot at 100,000 changes and sets `truncated: true`; a -truncated snapshot must not be presented as complete. +Core sorts `changes` by `path` and rejects duplicates. It caps a snapshot at +100,000 changes and sets `truncated: true`, as does a provider that stopped +early; a truncated snapshot must not be presented as complete. -Core rejects a plugin reply with `plugin.vcs.invalidOutput` when a path is -absolute, contains a `..` segment, or otherwise resolves outside the root, or -when a change carries `staging` although the provider did not negotiate the -`staging` capability. +### Other operations -## Core commands (planned) - -| Command | Purpose | -| --- | --- | -| `vcs.providers` | Registered providers, their origin and negotiated capabilities | -| `vcs.roots` | Detected roots, ambiguous directories, and applied user mappings | -| `vcs.status` | Status snapshot of one root | -| `vcs.baseContent` | Base-revision content of one file | -| `vcs.history` / `vcs.fileHistory` | Paged history with an opaque cursor | -| `vcs.annotate` | Per-line revision of one file | -| `vcs.checkin` / `vcs.rollback` / `vcs.update` | Mutations, with the existing execution-event lifecycle | -| `vcs.fileOperation` | Track an add, move, or delete made in the project tree | - -For the built-in Git provider Core executes the request itself. For a plugin -provider, Core follows the build-provider pattern: it returns -`provider: { method, params }`, the host sends it through the negotiated LSP -session, and Core decodes and validates the reply into the same result shape. -Exact plan/decode command names are fixed in P1. +| Operation | Reply | Rules | +| --- | --- | --- | +| `baseContent` | `{ content: string \| null, binary: bool }` | `null` content: the file has no base (added or unversioned); binary content is not sent. At most 32 MiB. | +| `history`, `fileHistory` | `{ entries: [{ revision, author, date, message, changes, changesTruncated }], nextCursor }` | Provider order (newest first) is kept; more entries than requested is invalid. Each entry lists at most 10,000 changes. | +| `annotate` | `{ revisions: [{ revision, author, date, message }], lines: [id \| null] }` | Each line names a listed revision ID or `null`; only used revisions are returned, sorted by ID. At most 500,000 lines. | +| `checkin` | `{ revision }` | The new revision, or `null` when the VCS does not report one. | +| `update` | `{ revision, conflicts: [path] }` | Conflicted paths, sorted and inside the root. | +| `rollback`, `fileOperation`, `resolve`, `cleanup` | `{}` | Unknown fields are ignored. | -## Plugin providers (planned, plugin API level 3) +A path that is absolute, has a `..` segment, or otherwise leaves the root, a +missing required field, or a size limit exceeded makes the reply invalid. -### Manifest +## Provider messages -```json -{ - "pluginAPIVersion": 3, - "requires": { "vcsProviders": 1, "serverProcess": 1 }, - "vcsProviders": [ - { - "id": "svn", - "displayName": "Subversion", - "detection": { "markers": [{ "name": ".svn", "type": "directory" }] }, - "metadataPaths": [".svn/wc.db"], - "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, - "launch": { "kind": "packaged", "path": "bin/lithe-svn-provider" } - } - ] -} -``` - -The manifest only states what the host must know before starting a process: -the VCS ID, detection markers, metadata paths to watch, the client tool the -provider drives, and the shared `serverProcess` launch fields. The host -resolves `tool.commands` with the same discovery as `systemCommand` launches -(PATH, user override) and sends the absolute path as `tool: { executable }` in -every request, as it does for build providers, so the provider does not repeat -tool lookup. A missing tool is reported once and the root stays visible as -unavailable rather than clean. - -A non-empty `vcsProviders` requires `languageServer.launch` (the plugin starts -its own process, like an own-process build provider), a new `process.vcs` -permission (the provider changes the working copy and contacts remote -servers), and the activation event `onVcsRoot:`. Detection never starts a -process; the host starts the provider after a root of its VCS is found, which -the install review must state. - -### Negotiation - -The host sends `capabilities.experimental.litheVcs: { "versions": [1] }` in -`initialize`. The provider answers -`{ "version": 1, "vcs": "", "capabilities": { … } }`. A session without a -valid answer, or with a `vcs` different from the manifest entry, is not used as -a provider and receives no `lithe/vcs/*` request. Capabilities belong to the -running process and are not repeated in the manifest. - -### Methods - -| Direction | Method | Kind | +| Direction | Method | Version 1 behavior | | --- | --- | --- | -| host → provider | `lithe/vcs/status`, `lithe/vcs/baseContent`, `lithe/vcs/history`, `lithe/vcs/fileHistory`, `lithe/vcs/annotate`, `lithe/vcs/checkin`, `lithe/vcs/rollback`, `lithe/vcs/update`, `lithe/vcs/fileOperation` | request | -| provider → host | `lithe/vcs/execution` | notification: an actual command started, produced output, or finished (project console) | -| provider → host | `lithe/vcs/authenticate` | request: credential challenge answered through the host's existing authentication UI; answers are never persisted by Lithe | +| provider → host | `lithe/vcs/execution` | Notification of a command the provider ran: `{ command: [executable, ...arguments], exitCode: number \| null, durationMilliseconds, stderr }`, with `stderr` cut to 16 KiB and no stdout. Core passes it on as a session `log` event (`message` is the method, `detail` the params as JSON); routing it to the project console is host work (P3). Arguments are shown as sent, so a provider never puts a credential in them. | +| provider → host | `lithe/vcs/authenticate` | Not accepted yet: Core answers `-32601`. A provider runs its client non-interactively and fails with `vcs.authentication.failed`. | -Both sides ignore unknown fields in params and results. Requests carry -`operationID`; `$/cancelRequest` and the host deadline end them with -`requestCancelled` or `requestTimeout`. +Both sides ignore unknown fields in params and results. `$/cancelRequest` and +the request deadline end a request with `requestCancelled` or +`requestTimeout`. -## Error codes (draft) +## Error codes -| Code | Meaning | -| --- | --- | -| `vcs.root.notFound` | The path is not inside a detected or mapped root | -| `vcs.root.ambiguous` | The directory matches several providers and is unmapped | -| `vcs.provider.unavailable` | The provider is not registered, not negotiated, or has exited | -| `vcs.capability.unsupported` | The request needs a capability the provider did not negotiate | -| `vcs.authentication.failed` | Credentials were rejected after the allowed retries | -| `vcs.outOfDate` | The server rejected a check-in because the working copy is out of date | -| `vcs.conflict` | The operation stopped on conflicted files | -| `plugin.vcs.invalidOutput` | A plugin reply failed validation or exceeded a limit | - -Platform details go in `details`; messages must not contain credentials. +| Code | Raised by | Meaning | +| --- | --- | --- | +| `vcs.provider.unavailable` | Core (`details` of `notSupported`) | The session negotiated no provider, or one for another VCS | +| `vcs.capability.unsupported` | Core (`details` of `notSupported`) | The operation needs a capability the provider did not negotiate | +| `plugin.vcs.reserved` | Core (manifest) | A plugin declared the reserved `git` ID | +| `plugin.vcs.invalidOutput` | Core | A reply failed validation, exceeded a limit, or had no result | +| `vcs.provider.failed` | Core | The provider failed without one of the codes below | +| `vcs.authentication.failed` | provider | Credentials were missing or rejected | +| `vcs.conflict` | provider | The operation stopped on conflicted files | +| `vcs.outOfDate` | provider | The server rejected a check-in because the working copy is out of date | +| `vcs.tool.unavailable` | provider | The VCS client could not be run | +| `vcs.workingCopy.locked` | provider | The working copy is locked by an interrupted operation; `cleanup` may help | +| `vcs.workingCopy.unsupported` | provider | The working copy format is not supported | + +A provider reports its code in the JSON-RPC `error.data.code`. Core keeps only +the provider codes listed here; any other code, including Core's own, becomes +`vcs.provider.failed`. The provider's message (at most 4 KiB) is passed on and +must not contain credentials. ## Subversion provider notes The official Subversion plugin calls the user's `svn` client and parses its `--xml` output (`status`, `info`, `log`, `blame`). Every invocation uses -`--non-interactive`; passwords use `--password-from-stdin`. Subversion's own -credential cache answers first. Working copies older than format 1.7 (one -`.svn` per directory) are not supported. +`--non-interactive`, so only Subversion's own credential cache answers. +Working copies older than format 1.7 (one `.svn` per directory) are reported +as `vcs.workingCopy.unsupported`. diff --git a/shared/fixtures/vcs/model-v1.json b/shared/fixtures/vcs/model-v1.json index d62109adac..afe2685038 100644 --- a/shared/fixtures/vcs/model-v1.json +++ b/shared/fixtures/vcs/model-v1.json @@ -6,26 +6,33 @@ { "vcs": "git", "displayName": "Git", - "origin": { "kind": "builtIn" }, - "capabilities": { - "annotate": 1, - "baseContent": 1, - "checkin": 1, - "conflicts": 1, - "fileHistory": 1, - "history": 1, - "ignore": 1, - "push": 1, - "rollback": 1, - "staging": 1, - "status": 1, - "update": 1 - } + "origin": { + "kind": "builtIn" + }, + "markers": [ + { + "name": ".git", + "type": "directory" + }, + { + "name": ".git", + "type": "file" + } + ] }, { "vcs": "svn", "displayName": "Subversion", - "origin": { "kind": "plugin", "pluginID": "dev.lithe.plugin.svn-support" }, + "origin": { + "kind": "plugin", + "pluginID": "dev.lithe.fixture.vcs-provider" + }, + "markers": [ + { + "name": ".svn", + "type": "directory" + } + ], "capabilities": { "annotate": 1, "baseContent": 1, @@ -35,8 +42,6 @@ "fileHistory": 1, "fileOperations": 1, "history": 1, - "ignore": 1, - "lock": 1, "rollback": 1, "status": 1, "update": 1 @@ -47,73 +52,386 @@ { "name": "nested Subversion working copy inside a Git repository", "markers": [ - { "directory": "", "name": ".git", "type": "directory" }, - { "directory": "services/legacy", "name": ".svn", "type": "directory" } + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "services/legacy", + "name": ".svn", + "type": "directory" + } ], "expectedRoots": [ - { "id": "", "vcs": "git" }, - { "id": "services/legacy", "vcs": "svn" } + { + "id": "", + "vcs": "git" + }, + { + "id": "services/legacy", + "vcs": "svn" + } ], "expectedOwnership": [ - { "path": "README.md", "root": "" }, - { "path": "services/legacy/src/Main.java", "root": "services/legacy" } + { + "path": "README.md", + "root": "" + }, + { + "path": "services/legacy/src/Main.java", + "root": "services/legacy" + }, + { + "path": "services/legacy-tools/a.sh", + "root": "" + } ], "expectedAmbiguous": [] }, { - "name": "one directory with markers of two providers stays unmapped", + "name": "an unmapped directory with markers of two providers owns nothing and blocks the outer root", "markers": [ - { "directory": "mixed", "name": ".git", "type": "directory" }, - { "directory": "mixed", "name": ".svn", "type": "directory" } + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": null + }, + { + "path": "mixed.txt", + "root": "" + } + ], + "expectedAmbiguous": [ + { + "id": "mixed", + "vcs": [ + "git", + "svn" + ] + } + ] + }, + { + "name": "a mapping chooses the provider of an ambiguous directory", + "markers": [ + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "mappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + }, + { + "id": "mixed", + "vcs": "svn" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "svn", + "mapped": true + } ], - "expectedRoots": [], - "expectedOwnership": [{ "path": "mixed/a.txt", "root": null }], - "expectedAmbiguous": [{ "id": "mixed", "vcs": ["git", "svn"] }] + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": "mixed" + } + ], + "expectedAmbiguous": [], + "expectedUnusedMappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + } + ] }, { "name": "Git worktree file marker", - "markers": [{ "directory": "linked", "name": ".git", "type": "file" }], - "expectedRoots": [{ "id": "linked", "vcs": "git" }], - "expectedOwnership": [{ "path": "linked/src/lib.rs", "root": "linked" }], + "markers": [ + { + "directory": "linked", + "name": ".git", + "type": "file" + } + ], + "expectedRoots": [ + { + "id": "linked", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "linked/src/lib.rs", + "root": "linked" + }, + { + "path": "other/a.txt", + "root": null + } + ], + "expectedAmbiguous": [] + }, + { + "name": "a workspace opened inside a Subversion working copy", + "workspace": "trunk/module", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "../..", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "src/Main.java", + "root": "../.." + } + ], + "expectedAmbiguous": [] + }, + { + "name": "metadata and dependency directories are not searched", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + }, + { + "directory": ".svn/pristine", + "name": ".git", + "type": "directory" + }, + { + "directory": "node_modules/left-pad", + "name": ".git", + "type": "directory" + }, + { + "directory": "target/checkout", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "node_modules/left-pad/index.js", + "root": "" + } + ], + "expectedAmbiguous": [] + }, + { + "name": "roots deeper than eight directories are not looked for", + "markers": [ + { + "directory": "a/b/c/d/e/f/g/h", + "name": ".svn", + "type": "directory" + }, + { + "directory": "z/b/c/d/e/f/g/h/i", + "name": ".git", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "a/b/c/d/e/f/g/h", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "z/b/c/d/e/f/g/h/i/a.txt", + "root": null + } + ], "expectedAmbiguous": [] } ], "statusSnapshots": [ { - "name": "git root with staging dimension", - "vcs": "git", + "name": "provider with a staging dimension", + "capabilities": { + "baseContent": 1, + "checkin": 1, + "rollback": 1, + "status": 1, + "staging": 1 + }, "snapshot": { - "root": "", "head": { - "revision": { "id": "4b825dc642cb6eb9a060e54bf8d69288fbee4904", "display": "4b825dc6", "kind": "hash" }, + "revision": { + "id": "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + "display": "4b825dc6", + "kind": "hash" + }, "branch": "preview", - "location": "https://github.example.invalid/owner/repo.git" + "location": "https://vcs.example.invalid/owner/repo.git" }, "changes": [ - { "path": "docs/new.md", "oldPath": null, "status": "added", "flags": [], "staging": "staged" }, - { "path": "src/lib.rs", "oldPath": null, "status": "modified", "flags": [], "staging": "partial" }, - { "path": "src/renamed.rs", "oldPath": "src/old.rs", "status": "renamed", "flags": [], "staging": "staged" }, - { "path": "vendor/module", "oldPath": null, "status": "modified", "flags": ["submodule"], "staging": "unstaged" } + { + "path": "docs/new.md", + "oldPath": null, + "status": "added", + "flags": [], + "staging": "staged" + }, + { + "path": "src/lib.rs", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "partial" + }, + { + "path": "src/renamed.rs", + "oldPath": "src/old.rs", + "status": "renamed", + "flags": [], + "staging": "staged" + }, + { + "path": "vendor/module", + "oldPath": null, + "status": "modified", + "flags": [ + "submodule" + ], + "staging": "unstaged" + } ], "truncated": false } }, { "name": "svn root without staging, with Subversion-only flags", - "vcs": "svn", + "capabilities": { + "annotate": 1, + "baseContent": 1, + "checkin": 1, + "cleanup": 1, + "conflicts": 1, + "fileHistory": 1, + "fileOperations": 1, + "history": 1, + "rollback": 1, + "status": 1, + "update": 1 + }, "snapshot": { - "root": "services/legacy", "head": { - "revision": { "id": "1234", "display": "r1234", "kind": "number" }, + "revision": { + "id": "1234", + "display": "r1234", + "kind": "number" + }, "branch": null, "location": "https://svn.example.invalid/repo/trunk" }, "changes": [ - { "path": "build.xml", "oldPath": null, "status": "missing", "flags": [] }, - { "path": "lib/vendor", "oldPath": null, "status": "unversioned", "flags": ["external"] }, - { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["propertiesModified", "locked"] }, - { "path": "src/Moved.java", "oldPath": "src/Old.java", "status": "renamed", "flags": [] }, - { "path": "src/Shared.java", "oldPath": null, "status": "conflicted", "flags": ["treeConflict"] } + { + "path": "build.xml", + "oldPath": null, + "status": "missing", + "flags": [] + }, + { + "path": "lib/vendor", + "oldPath": null, + "status": "unversioned", + "flags": [ + "external" + ] + }, + { + "path": "src/Main.java", + "oldPath": null, + "status": "modified", + "flags": [ + "locked", + "propertiesModified" + ] + }, + { + "path": "src/Moved.java", + "oldPath": "src/Old.java", + "status": "renamed", + "flags": [] + }, + { + "path": "src/Shared.java", + "oldPath": null, + "status": "conflicted", + "flags": [ + "treeConflict" + ] + } ], "truncated": false } @@ -122,36 +440,224 @@ "pluginReplyValidationCases": [ { "name": "unknown status value is rejected", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "obstructed", "flags": [] }] }, + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "obstructed", + "flags": [] + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "path escaping the root is rejected", - "reply": { "changes": [{ "path": "../outside.txt", "oldPath": null, "status": "modified", "flags": [] }] }, + "reply": { + "changes": [ + { + "path": "../outside.txt", + "oldPath": null, + "status": "modified", + "flags": [] + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "unknown flag is dropped, change is kept", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": ["futureFlag", "locked"] }] }, - "expectedChanges": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": ["locked"] }] + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "futureFlag", + "locked" + ] + } + ] + }, + "expectedChanges": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "locked" + ] + } + ] }, { "name": "staging field from a provider without staging is rejected", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": [], "staging": "staged" }] }, + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "staged" + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "unordered changes are sorted by path", "reply": { "changes": [ - { "path": "b.txt", "oldPath": null, "status": "added", "flags": [] }, - { "path": "a.txt", "oldPath": null, "status": "deleted", "flags": [] } + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + }, + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + } ] }, "expectedChanges": [ - { "path": "a.txt", "oldPath": null, "status": "deleted", "flags": [] }, - { "path": "b.txt", "oldPath": null, "status": "added", "flags": [] } + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + }, + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + } ] + }, + { + "name": "credentials, query, and fragment leave the head location", + "reply": { + "head": { + "revision": { + "id": "7" + }, + "location": "https://alice:secret@svn.example.invalid/repo/trunk?p=1#top" + }, + "changes": [] + }, + "expectedResult": { + "head": { + "revision": { + "id": "7", + "display": "7", + "kind": "opaque" + }, + "branch": null, + "location": "https://svn.example.invalid/repo/trunk" + }, + "changes": [], + "truncated": false + } + }, + { + "name": "an original path on a modification is rejected", + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": "b.txt", + "status": "modified", + "flags": [] + } + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "a history page longer than the request is rejected", + "operation": "history", + "request": { + "limit": 1 + }, + "reply": { + "entries": [ + { + "revision": { + "id": "2" + }, + "author": "a", + "date": "2026-10-22T00:00:00Z", + "message": "m" + }, + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "nextCursor": null + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "an annotated line must name a listed revision", + "operation": "annotate", + "request": { + "path": "a.txt" + }, + "reply": { + "revisions": [ + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "lines": [ + "1", + "2" + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "update conflicts are sorted and stay inside the root", + "operation": "update", + "request": {}, + "reply": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "b.txt", + "a.txt" + ] + }, + "expectedResult": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "a.txt", + "b.txt" + ] + } } ] } diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index c54f270a5c..93e0744031 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -7,6 +7,10 @@ "evidence": [ "shared/contracts/vcs-provider-v1.md", "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json", ".agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md" ], "implementationStatus": "missing", @@ -15,12 +19,16 @@ "windows": { "evidence": [ "shared/contracts/vcs-provider-v1.md", - "shared/fixtures/vcs/model-v1.json" + "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变。", - "notes": "Issue #827。当前只有提议 Note、契约草案和 fixtures,两端都没有实现入口;分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。宿主验收:安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 的 litheVcs 支持随 SDK 迁到 lithe-plugin-docs(P1);两端宿主尚未声明 vcsProviders,也没有调用这些命令,所以仍记为未实现。分阶段计划见提议 Note。" } From bb611473084e226e51dcc32f6825ec5611e65013 Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 21:12:36 +0800 Subject: [PATCH 10/50] =?UTF-8?q?feat(vcs):=20macOS=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=8F=92=E4=BB=B6=20VCS=20Provider=EF=BC=8CS?= =?UTF-8?q?VN=20=E6=94=B9=E4=B8=BA=E7=8B=AC=E7=AB=8B=E7=A4=BE=E5=8C=BA?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=EF=BC=88#827=20P3=EF=BC=89=20(#1441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vcs): Core 实现插件 VCS Provider 底层(#827 P1) - 插件清单 API 3 新增 vcsProviders 能力、process.vcs 权限和 onVcsRoot 激活事件,git 为保留 ID;安装审查以 vcsTool 列出 VCS 命令行 - 新增 vcs.detectRoots(静态标记识别根、歧义目录与用户映射、祖先工作副本)、vcs.ownerOf(最深根归属)、vcs.decodeReply(Provider 回复校验与归一化) - LSP 会话:只向插件会话提供 litheVcs 协商,成功后发出 vcsProvider 事件;lsp.vcsRequest 按操作生成 lithe/vcs/* 请求并检查已协商能力;Provider 错误码只保留契约白名单 - lithe/vcs/execution 转为会话日志事件,lithe/vcs/authenticate 暂按未知请求回绝 - 契约、插件 README/schema、rust-core-api、fixtures、功能矩阵与提议 Note 同步 - 修复 unregistering_a_running_function_revokes_the_call 的竞态:改为有界等待 $/cancelRequest 写出 * feat(sdk): Node SDK 支持 litheVcs 并用示例 Provider 端到端验证(#827 P1) - backend.vcs(id, handlers, traits) 按声明的处理函数协商 VCS 能力,必需 status/baseContent/checkin/rollback,git 保留,litheVcs 不能手写进 capabilities - 只在宿主提供 litheVcs 1 时分发 lithe/vcs/* 请求;回复必须是对象,无返回值视为 {} - VCS 处理函数的 context.run 自动发出 lithe/vcs/execution(命令行、退出码、耗时、截断到 16 KiB 的 stderr),可用 report: false 关闭 - 导出 VCS_ERRORS 与 isVcsId,补充类型声明、README 和单元测试 - 新增 sdk/node/examples/sample-vcs 玩具 VCS,Rust 端到端测试经真实 Core 会话跑通协商、全部 11 种操作、错误码和执行报告 - 契约写明 lithe/vcs/execution 的参数;Note 与功能矩阵更新 P1 完成状态 * fix(vcs): 按评审修正祖先根判断与示例移动回滚(#827 P1) - 只有全部由 .. 段组成的根 ID 才是工作区外的祖先根;工作区内名为 ..repo 的目录按普通路径计算深度和包含关系,补充单元测试 - 示例 Provider 回滚移动目标时把文件移回原路径、恢复基线内容并清除移动记录;端到端测试覆盖移动后回滚 - 插件能力版本笔记补充 vcsProviders、其对 serverProcess 的依赖、两端宿主尚未声明的状态,以及 litheVcs 协商 * feat(vcs): macOS 宿主接入插件 VCS Provider(#827 P3) SVN 插件改为独立仓库 Lithe-IDEA/lithe-plugin-svn 的社区插件 (io.github.lithe-idea.svn-support),主仓库只保留与具体 VCS 无关的部分; 原 #1432 里宿主识别 vcsProviders 的通用改动并入本提交。 - 宿主识别:声明式清单解码 vcsProviders,声明了 VCS Provider 的插件生成一个 .vcs-providers 扩展模块,只承载启用状态,默认关闭; PluginManifest.hostCapabilities 声明 vcsProviders: [1]。 - 安装审核:显示 Provider 的启动命令、客户端命令和是否连接仓库服务器。 - Provider 会话、Core 调用(vcs.detectRoots / ownerOf / decodeReply、 lsp.vcsRequest)、VcsProvidersFeatureModel、左侧 VCS 工具窗口、历史与差异、 项目树状态着色和移动跟踪。 - 测试改用契约里的 svn-provider 清单 fixture 和 SDK 的 sample-vcs 样例, 不再依赖 SVN 插件源码。 - 契约的 Subversion provider notes 改为指向插件仓库;Note、功能矩阵和 Plugins/mac/README.md 同步说明社区插件。 * fix(vcs): 按自审修正 VCS Provider 宿主(#827 P3) - 包住工作区的工作副本(如 ../.svn)单独监听其声明的元数据目录, 终端里的提交或更新也会触发刷新。 - 移动前按 Core 的归属规则确认两端都属于同一根,嵌套的 Git 仓库或 未选择 VCS 的歧义目录不会被当成 svn move。 - 窗口关闭时调用 shutdown():停掉所有 Provider,之后迟到的工作区 扫描也不会再把 Provider 拉起来。 - 工具窗口与命令沿用宿主版本控制图标;首次读取时显示“正在读取更改…”; 变更行补充选择、打开/查看差异、纳入提交的辅助功能操作。 - 同步 Agent Note、能力版本 Note 与跨平台功能矩阵。 * fix(vcs): 隐藏 .svn 并按根监听元数据,补齐工具缺失与比较判断(#827 P3) - 项目树、搜索、索引与工作区监听像 .git 一样隐藏 .svn(Swift 与 Rust Core 的内置隐藏列表同步),避免 .svn/pristine 进入索引 - 每个插件根单独监听自己的元数据目录(含包住工作区的 ../.svn);工作区里新出现的条目在后台检查标记,新检出的工作副本会重新识别 - 找不到 VCS 客户端时按契约报 vcs.tool.unavailable,工具窗口先显示安装提示 - 是否有基线统一由 VcsRootState.comparesWithBase 判断,工具窗口双击、辅助功能动作和项目树右键共用 - 等待 Core 判断移动归属期间开始了别的操作时,直接退回磁盘移动 - 同步 Agent Note、功能矩阵与 VcsProviderHostTests --- .../2026-10-17-plugin-capability-versions.md | 2 +- .../2026-10-22-unified-vcs-provider-model.md | 51 +- Plugins/mac/README.md | 12 + .../zh-Hans.lproj/Localizable.strings | 70 ++ .../Application/Composition/AppServices.swift | 2 + .../WorkspaceProjectionComposition.swift | 3 + .../Features/VcsProvidersFeatureModel.swift | 862 ++++++++++++++++ .../Features/WorkspaceFeatureModel.swift | 2 + .../Lithe/Core/Rust/RustCoreBridge.swift | 36 + .../RustDeclarativePluginOperations.swift | 35 + .../RustLanguageServerRuntimeAdapter.swift | 17 + .../Lithe/Core/Rust/RustVcsOperations.swift | 151 +++ .../AppModel+LanguageServerRuntime.swift | 1 + .../AppModel/AppModel+PluginManagement.swift | 2 + .../AppModel/AppModel+RunConfiguration.swift | 1 + .../AppModel/AppModel+VcsProviders.swift | 22 + .../Lithe/Models/AppModel/AppModel.swift | 5 + .../Platform/MacOS/MacServiceContainer.swift | 48 +- ...acPluginLanguageServerLaunchResolver.swift | 91 +- .../MacOS/Vcs/MacVcsProviderSessions.swift | 347 +++++++ .../MacOS/Vcs/MacVcsRootMappingStore.swift | 33 + .../Views/App/PluginMarketplaceView.swift | 22 + macos/Sources/Lithe/Views/App/RootView.swift | 4 + .../Vcs/VcsChangeStatusPresentation.swift | 67 ++ .../Vcs/VcsProviderHistoryAndDiffViews.swift | 266 +++++ .../Views/Vcs/VcsProviderToolWindowView.swift | 730 +++++++++++++ .../VcsProviderToolWindowRegistration.swift | 95 ++ .../WorkbenchModuleUIComposition.swift | 15 +- .../Views/Workspace/ProjectSidebarView.swift | 74 +- .../LanguageServerRuntimeContracts.swift | 27 + .../Plugins/DeclarativePluginContracts.swift | 62 +- .../Plugins/PluginMarketplaceContracts.swift | 24 + .../Vcs/VcsProviderContracts.swift | 438 ++++++++ .../Workspace/FileVisibilityRules.swift | 4 +- .../Runtime/LanguageServerSession.swift | 55 + .../Catalog/BuiltInModuleCatalog.swift | 18 + .../Lifecycle/ModuleTypes.swift | 5 + .../LitheModuleAPI/Plugins/PluginTypes.swift | 3 +- .../Application/WorkspaceFeatureModel.swift | 21 +- .../LitheTests/AppLocalizationTests.swift | 42 + .../Tests/LitheTests/PluginManagerTests.swift | 20 + .../LitheTests/VcsProviderHostTests.swift | 975 ++++++++++++++++++ rust/lithe-core/src/project/files.rs | 3 +- shared/contracts/vcs-provider-v1.md | 17 +- .../vcs-subversion-and-plugin-providers.json | 22 +- 45 files changed, 4749 insertions(+), 53 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift create mode 100644 macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift create mode 100644 macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift create mode 100644 macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift create mode 100644 macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift create mode 100644 macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift create mode 100644 macos/Tests/LitheTests/VcsProviderHostTests.swift diff --git a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md index 22e9e85898..06dc242628 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +++ b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md @@ -59,7 +59,7 @@ Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.de 能力表放在宿主代码里,写在能力实现的旁边,Core 不再按平台去猜。原来按 `darwin-` 前缀和级别写死的判断,就是 Core 替宿主猜、又容易和宿主实现漂移的例子。 -macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。`vcsProviders: 1` 也是这样:Core 已经能校验清单、识别工作副本根并转发请求,但 macOS 和 Windows 都还没有接入 VCS Provider 会话,所以两端的能力表都不包含它,需要它的插件在两端都不可安装。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 +macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,`buildProviders: [1]` 在 macOS 接入构建 Provider 会话后才加入。`vcsProviders: [1]` 同样等 macOS 接入 VCS Provider 会话(版本控制工具窗口、项目树状态和文件移动)后才加入,见[统一 VCS 模型提案](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)的 P3。Core 自己支持 `buildProviders: 1` 和 `vcsProviders: 1`,不传宿主表时仍可校验清单、生成 Provider 计划、识别工作副本根并转发请求。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 ### 3. 市场索引带上能力版本 diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index bf907e0b8a..79bf7fd3f9 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -6,7 +6,7 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提交面板、项目树文件颜色、编辑器行标记(gutter)都直接使用 Git 类型。[Issue #827](https://github.com/1lck/Lithe-IDEA/issues/827) 要求支持 SVN,以后还可能有 Mercurial、Perforce 等其他 VCS(Version Control System,版本控制系统)。 -提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。SVN 作为第一个官方插件 Provider,后端用官方 Node SDK 编写,以插件进程的形式接入,后续其他 VCS 走同一条路。 +提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。SVN 作为第一个插件 Provider,后端用官方 Node SDK 编写,以插件进程的形式接入,后续其他 VCS 走同一条路。2026-10-23 决定 SVN 插件不放进主仓库,而是像 Cargo 插件一样做成独立仓库的社区插件([lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn));主仓库只保留与具体 VCS 无关的 Core、契约、SDK 和宿主。 2026-10-22 评审决定:**Git 暂不接入统一层**。现有 Git 代码约 8 万行(Rust、Swift、TypeScript 合计,不含测试),两端还有重复的确定性逻辑,直接“包一层”会把这些债一起带进统一层。所以第一期统一层只服务插件 Provider,Git 保持现有 `git.*` 和 Git 模块不变;Git 的精简、迁移和改成内置官方插件另起提案(第 10 节)。 @@ -94,9 +94,9 @@ Git Log 图、Rebase、Worktree、Stash、Push 和 Fetch 对话框、历史改 字段和方法见 [VCS Provider 契约](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。Core 已按契约实现(见第 9 节的进度),但还没有宿主声明 `vcsProviders`,所以插件市场不会列出这类插件;宿主接入前契约仍可以修改,改动要同时更新 fixtures 和 Core 测试。 -### 7. SVN 作为官方插件 +### 7. SVN 作为社区插件 -SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 输出。IDEA 的 svn4idea 也是这样做的(`plugins/svn4idea/src/org/jetbrains/idea/svn/status/CmdStatusClient.kt` 使用 `svn status --xml`)。这和 Lithe 使用系统 Git 的方式一致,不需要打包 JVM 或跨平台编译 libsvn。 +SVN 插件在独立仓库维护和发布(见第 9 节 P2),调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 输出。IDEA 的 svn4idea 也是这样做的(`plugins/svn4idea/src/org/jetbrains/idea/svn/status/CmdStatusClient.kt` 使用 `svn status --xml`)。这和 Lithe 使用系统 Git 的方式一致,不需要打包 JVM 或跨平台编译 libsvn。 后端进程用官方 Node SDK([lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) 的 `sdk/node`)编写,以 `runtime` 方式启动(`tool: node`)。SDK 已经负责线协议、协商、取消、子进程清理和错误映射,Cargo 社区插件也走这条路;同一份后端在 macOS 和 Windows 上都能运行,插件包里没有原生二进制,不涉及按架构编译和 Gatekeeper。代价是用户除了 `svn` 还需要 Node 20.3 以上,安装审查和缺少运行时的提示要说清楚。SDK 新增 `litheVcs` 支持,和 `litheBuild` 并列;2026-10-23 起 SDK 不在主仓库,这部分改动在 lithe-plugin-docs 提交(见[插件契约、SDK 与开发者资料的发布](../../implemented/architecture/2026-10-23-plugin-contract-publishing.md))。 @@ -117,7 +117,7 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` | --- | --- | --- | | P0 | 本提案、契约草案和 fixtures | 评审通过 | | P1 | Core:插件清单 `vcsProviders` 能力、根识别、请求计划与插件回复解码、`litheVcs` 协商和 `lsp.vcsRequest`;Node SDK 支持 `litheVcs` | fixtures 和用 SDK 写的假 Provider 经真实 Core 会话跑通 | -| P2 | SVN 官方插件(Node SDK 后端) | 录制的 `svn --xml` 样例解析测试;本地 `file://` 仓库上的端到端测试 | +| P2 | SVN 社区插件(独立仓库,Node SDK 后端) | 插件仓库里录制的 `svn --xml` 样例解析测试,以及用固定版本 Core 在本地 `file://` 仓库上跑的端到端测试 | | P3 | macOS 宿主:Provider 会话(复用构建 Provider 的会话代码)、项目树颜色和 gutter 合并展示插件根、通用“版本控制”工具窗口(变更、提交、回滚、更新)、文件操作跟踪、交互式认证 | 见验收标准 | | P4 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | | 之后 | Git 精简并接入统一层,见第 10 节 | 另起提案 | @@ -129,6 +129,35 @@ P1 已完成(2026-10-22): - 会话:`litheVcs` 只提供给插件启动的会话;`lsp.vcsRequest` 按操作名生成 `lithe/vcs/*` 请求,未协商的能力在写出前就拒绝;Provider 只能使用契约列出的错误码,其余一律变成 `vcs.provider.failed`。`lithe/vcs/authenticate` 在宿主实现交互式认证前按未知请求回绝。 - Node SDK:`backend.vcs(id, handlers, traits)` 按声明的处理函数协商能力,VCS 处理函数里的 `context.run` 会自动发出 `lithe/vcs/execution` 执行报告(只带命令行、退出码、耗时和截断后的 stderr,不带 stdout)。示例 `sdk/node/examples/sample-vcs` 是一个把修订存在 JSON 文件里的玩具 VCS,端到端场景经真实 Core 会话跑通它的协商和全部 11 种操作。SDK、示例和端到端场景随 SDK 迁到 lithe-plugin-docs,在那里用 `scripts/sdk-e2e.mjs --ref <本分支>` 运行;主仓库只保留契约、fixtures 和 Core 测试。 +P2 已完成(2026-10-23),同日改为社区插件: + +- 插件在独立仓库 [lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn),ID 是 `io.github.lithe-idea.svn-support`,`vendor.signatureRequirement` 为 `none`。仓库结构照搬 [Cargo 插件](../../implemented/feature/2026-10-22-cargo-build-plugin.md):`sdk.lock.json` 按上游提交和逐文件 SHA-256 固定 Node SDK,打包前校验;安装包发在插件仓库的 GitHub Releases,Lithe Store 索引记录下载地址和 SHA-256。 +- 插件仓库自己的 CI 按 `sdk.lock.json` 的同一个宿主提交检出 Core,经公开 JSON 接口在 `svnadmin create` 建立的 `file://` 仓库上跑状态、提交、更新、冲突、回滚、移动、历史和 Annotate。svn 怎样映射到每个操作、svn 错误码怎样映射到契约错误码,都写在插件仓库的 README 里,主仓库的契约只留一个指向。 +- 主仓库不再有 SVN 专属的代码和测试,CI 也不用装 svn。它保留通用的部分:契约、fixtures 和 Core 测试,以及宿主测试用的 `shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json`(只有清单,没有实现)。样例 Provider `sample-vcs` 和它的端到端场景随 SDK 在 lithe-plugin-docs。 + +正确做法:主仓库改 VCS 契约或 Core 时,用 fixtures 和 Core 测试验证,合并前在 lithe-plugin-docs 用 `scripts/sdk-e2e.mjs --ref <分支>` 跑样例 Provider;SVN 插件升级 `sdk.lock.json` 后在自己的 CI 里验证。 +不要这样做:在主仓库测试里引用隔壁的 SVN 插件 checkout,或者为 SVN 在宿主代码里加按名字判断的分支。 + +宿主需要知道的 SVN 行为(插件 README 有完整说明): + +- `head` 是根目录自己的工作修订(`svn info` 的结果)。提交后根目录停在旧修订,要 `update` 才前进,这是 SVN 的混合修订语义,宿主不要拿它判断“提交没成功”。 +- svn 每个写命令结束前会等约 1 秒,保证之后的修改时间戳不同,宿主不要指望写操作更快完成。 +- 第一版 `status` 每次查整个根,契约 v1 也没有按路径查询的参数。增量查询要等量出大工作副本的实际耗时后再加,要加就按能力版本加参数。 + +P3 第一部分已完成(2026-10-23),在 macOS 上: + +- 宿主在 `PluginManifest.hostCapabilities` 声明 `vcsProviders: [1]`,声明了 VCS Provider 的插件从这时起能安装。宿主读清单时给这类插件生成一个 `<插件 ID>.vcs-providers` 模块,只承载插件的启用状态,默认关闭,做法和构建系统插件的 `.build-systems` 模块一样。安装审查给三种 Provider 程序(插件内、系统已装、运行时启动)显示启动命令、`vcsTool` 里的客户端命令,以及 `mayUseNetwork` 声明的“可能连接仓库服务器”,让用户批准前知道插件会运行哪个 `svn`。客户端命令和 Provider 自己的运行时一样经构建工具发现查找,找不到时报契约的 `vcs.tool.unavailable`,工具窗口据此先显示安装提示。 +- Provider 会话按窗口创建:启动时已安装的插件声明了 `vcsProviders`,`MacServiceContainer` 才建 `VcsProvidersFeatureModel`。启动后再安装的插件要重启才生效,工具窗口这时只显示“重启 Lithe 后使用”。这样做是因为服务在窗口组合时一次建好,运行中插桩会话要改动整个组合根,收益只是省一次重启。 +- 每个 Provider 贡献一个左侧工具窗口,宿主只认贡献上的 `vcsProviderPluginID`、`vcsProviderID` 两个可见性键,代码里没有任何 VCS 或插件的名字(与构建系统窗口同一套注册方式)。Provider 不声明图标,派生的窗口和命令统一用宿主的版本控制图标(`git` 宿主图标,即 `vcs@20x20.svg`),不用通用工具窗口图标。第一次读状态时 Provider 进程才启动,可能要几秒,变更列表这时显示“正在读取更改…”,不留空白。变更行在辅助功能里是一个合并元素,默认动作是选中,另有“显示差异/打开文件”和“包含在提交中”两个命名动作,否则辅助功能只能切换复选框。窗口按冲突、变更、未版本化文件分组:默认只勾选“变更”组,未版本化文件要用户勾选才提交,冲突文件不能勾选,要先“标记为已解决”;移动的文件提交时连同原路径一起提交,否则 SVN 会只提交一半。按钮是否出现看 Provider 协商的能力,不看 VCS 名字。 +- 与基线比较做成窗口级的弹出面板(`VcsProvidersFeatureModel.presentedDiff`,由 `RootView` 显示),不挂在工具窗口里,因为项目树右键的“显示差异”在工具窗口隐藏时也要能用。比较复用本地历史的 `LocalHistoryDiffBuilder` 和 Git 的 `DiffPaneView`;超过 2 万行时退成左右两栏纯文本,避免逐行比较卡住界面。有没有基线由 `VcsRootState.comparesWithBase` 一处判断,工具窗口双击、辅助功能动作和项目树右键共用:新增、未版本化、忽略的文件没有基线,双击直接打开;复制的文件和复制来源比较。 +- 项目树颜色:插件根的状态通过一个只观察 `VcsProvidersFeatureModel` 的包装视图传给项目树,项目树本身仍是 `.equatable()`,刷新状态不重绘整棵树。同一文件 Git 和插件都有状态时用 Git 的,因为第一期 Git 不在统一层,嵌套时以 Git 的显示为准,不让两套颜色互相覆盖。 +- 文件操作跟踪:工作区模块只多一个注入的 `moveTrackedItem` 回调,不认识任何 VCS。项目树里的重命名和移动先交给拥有该文件的 Provider(`svn move`);Provider 不跟踪这个文件或拒绝时,退回原来的磁盘移动。源路径和目标路径都要用 Core 的 `vcs.ownerOf` 判断归属,两端都属于同一个插件根才交给 Provider:嵌套的 Git 仓库、还没选 VCS 的歧义目录都不属于外层工作副本。只按“最深的插件根”判断会把移进嵌套 Git 仓库的文件也交给 `svn move`。等待 Core 判断归属期间如果开始了别的操作,移动直接退回磁盘移动,和操作开始前就在运行时一样,不报“等正在运行的操作结束”。 +- 刷新:工作区监听和项目树一样隐藏 VCS 元数据目录。内置隐藏列表里有 `.git` 和 `.svn`,与 IDEA 默认的忽略列表一致;不隐藏的话,`.svn/pristine` 里每个文件的基线副本会进入项目树、搜索、本地历史和索引。所以每个插件根单独监听自己的标记目录,例如 `.svn`,或工作区开在工作副本子目录里时的 `../.svn`。只监听 `metadataPaths` 所在的标记目录,不监听整个外层工作副本。工作文件的变化仍走工作区事件,两路事件走同一条过滤和合并逻辑,合并后再刷新受影响的根。元数据目录里只有 Provider 声明的 `metadataPaths`(SVN 是 `.svn/wc.db`)会触发刷新。宿主自己的请求执行期间和结束后的短暂时间内忽略元数据变化,否则每次提交、甚至每次读状态都会再刷新一遍;代价是命令行在这段时间里的改动,要等下一次刷新才显示。元数据监听丢了事件,或标记目录消失时,按标记变化处理,重新识别根。标记目录隐藏后,工作区事件里看不到新出现的 `.svn`。所以新建的条目和它所在的目录如果不是已知的根,就在后台线程查一次有没有标记,有才重新识别,例如在工作区里 checkout 了新工作副本,或 update 拉下了 external。 +- 生命周期:关闭工作区、切换工作区,以及原生项目窗口关闭(`shutdownProjectSession`,这时工作区可能被设置窗口保留而不关闭)都会在第一次挂起前清空 `VcsProvidersFeatureModel`:取消识别和刷新任务,停止该工作区启动中和运行中的 Provider 进程,停掉元数据监听。窗口关闭走 `shutdown()`,之后这个窗口的模型不再加载任何工作区:关闭时还在进行的工作区扫描随后调用 `loadVcsProviders` 也不会把 Provider 重新拉起来;关闭的会话模型不会被复用,所以不需要恢复入口。 +- 歧义目录的选择按工作区存在应用偏好里(`Vcs.RootMappings.<工作区摘要>`),不写工作区,也不写 app bundle。识别时目录不再歧义,就丢掉旧选择。 + +P3 剩下的部分:编辑器 gutter 与 Annotate、交互式认证(`lithe/vcs/authenticate` 仍按未知请求回绝)、把 `lithe/vcs/execution` 执行报告接到项目控制台,以及在 Lithe Store 上架 SVN 插件。上架要等带 `vcsProviders` 能力的 Lithe 版本发布后再做,否则旧版本用户会在市场里看到装不上的插件;P1 合入后,插件仓库的 `sdk.lock.json` 也要改指向 `preview` 上的提交。 + ### 10. Git 什么时候、怎样接入 Git 接入不在本提案的范围内,这里只记下评审时已经确认的方向,供以后的提案使用: @@ -161,7 +190,11 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 ### SVN 内置到宿主 -首发更快,也不需要先做插件能力。但插件 VCS 这条链路要等到第三种 VCS 出现才会被真实验证,而且宿主里会出现 SVN 专属代码。用 SVN 作为第一个插件 Provider,可以在真实需求下验证整条插件链路,因此选择官方插件。 +首发更快,也不需要先做插件能力。但插件 VCS 这条链路要等到第三种 VCS 出现才会被真实验证,而且宿主里会出现 SVN 专属代码。用 SVN 作为第一个插件 Provider,可以在真实需求下验证整条插件链路,因此做成插件。 + +### SVN 作为官方插件留在主仓库 + +P2 的第一版就是这样:插件放在 `Plugins/mac/Official/SvnSupport/`,用 Lithe 的发布者私钥签名,进官方插件目录。好处是主仓库改 Core 时,SVN 端到端测试会一起跑。但插件每次修改都要走主仓库的 PR、CI 和官方签名发布,和 Lithe 的发版节奏绑在一起,主仓库 CI 还得装 svn;Go、PHP、Cargo 插件已经因为同样的原因搬到了独立仓库。改为社区插件后,插件可以独立发布,信任来自 Lithe Store 的审核、SHA-256 和安装确认框;契约回归由主仓库的样例 Provider 覆盖,SVN 自己的回归由插件仓库 CI 用固定版本 Core 覆盖。因此不采用。 ### 用 SVNKit 或 libsvn 嵌入 SVN @@ -174,7 +207,7 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 ## 验收标准 - P1:清单校验接受和拒绝的情况都有 fixture;根识别覆盖嵌套、歧义和 Git worktree 文件标记;插件回复的校验用例全部通过;用 Node SDK 写的假 Provider 经真实 Core 会话完成协商和每种请求,未协商成功的进程收不到 `lithe/vcs/*` 请求。 -- P2:SVN 插件对录制的 `svn status/info/log/blame --xml` 样例解析正确;在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete、Annotate、文件历史和冲突显示。 +- P2(在插件仓库 CI 中运行):SVN 插件对录制的 `svn status/info/log/blame --xml` 样例解析正确;在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete、Annotate、文件历史和冲突显示。 - P3:macOS 上安装 SVN 插件后完成安装审查、根识别、文件颜色、gutter、提交、回滚、更新和进程清理;停用或卸载插件后进程退出,它的根从界面消失;Git 项目的行为没有变化;密码不出现在进程参数、控制台和日志中。 - 每个阶段都运行对应的 Core、契约、Note 和平台验证脚本;Windows 的结论只来自 Windows 上的实际运行。 @@ -182,7 +215,7 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 - **统一层只被一种 VCS 验证过。** 第一期只有 SVN 用统一层,模型里可能藏着 SVN 的偏见。缓解办法:模型只收第 2 节表里 Git 和 SVN 都成立的概念,Git 专属的暂存维度已经作为可选能力进入 fixtures;Git 接入时如果发现缺口,按能力版本加字段。 - **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。第一期不碰 Git,`git.*` 不冻结。 -- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 +- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢。第一版每次查整个根,宿主先合并刷新事件、限制刷新频率;实测太慢时再按能力版本加按路径的增量查询。 - **混合根和嵌套根。** Git 仓库里嵌套 SVN 工作副本时,文件必须只归属一个根,否则会重复显示或重复提交。识别规则和用户映射都要有 fixture 覆盖。 - **插件进程常驻的成本。** 只在识别到对应根时启动,关闭工作区即回收,规则与构建 Provider 相同。 - **回退方式。** 每个阶段都只新增路径,停用 SVN 插件即回到现状。 @@ -192,11 +225,15 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 - `rust/lithe-core/src/vcs/` - `rust/lithe-core/src/plugins/` - `rust/lithe-core/src/lsp/interface/` +- [SVN 插件仓库](https://github.com/Lithe-IDEA/lithe-plugin-svn) - `rust/lithe-core/src/git/` - `rust/lithe-git-host/` - `macos/Sources/LitheGitModule/` - `macos/Sources/Lithe/Views/Git/` - `macos/Sources/Lithe/Views/Workspace/` +- `macos/Sources/Lithe/Views/Vcs/` +- `macos/Sources/Lithe/Platform/MacOS/Vcs/` +- `macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift` - `macos/Sources/Lithe/Views/Editor/` - `windows/tauri/src/features/git/` - `shared/contracts/plugins/v1/` diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 0f69c20fe2..25c8bb4887 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -47,6 +47,18 @@ Cargo-specific compatibility tests run in the plugin repository against a pinned Lithe Core through its public JSON API. This repository retains generic plugin contract tests; it does not keep Cargo community plugin snapshots. +## Subversion community plugin + +[Subversion](https://github.com/Lithe-IDEA/lithe-plugin-svn) +(`io.github.lithe-idea.svn-support`) is a VCS provider plugin maintained and +released independently in the same way. It needs a Lithe build that declares +the `vcsProviders` host capability, and runs the user's `svn` 1.8+ through a +Node SDK provider. Its svn output fixtures and Core compatibility tests live in +the plugin repository. This repository keeps the generic VCS provider contract, +its manifest fixture, and the Core tests; the SDK's sample provider +(`sample-vcs`) lives with the SDK in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs). + ## Downloadable official plugins No downloadable official plugin is configured right now: diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 41c899e7d3..0d6f203a94 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3716,6 +3716,76 @@ /* 内嵌 HTML 预览浏览器:右键菜单、打印和 PDF。 */ "Print…" = "打印…"; "Export as PDF…" = "导出为 PDF…"; + +/* 插件 VCS Provider:安装审核、工具窗口、项目树和差异比较。 */ +"%@ provider (inside the plugin)" = "%@ Provider(插件内置)"; +"%@ provider (installed on this Mac)" = "%@ Provider(使用本机已安装的程序)"; +"%1$@ provider (runs with %2$@)" = "%1$@ Provider(使用 %2$@ 运行)"; +"Client: %@" = "客户端:%@"; +"May connect to the repository server" = "可能连接仓库服务器"; +"Show %@ Diff" = "显示 %@ 差异"; +"%1$@ does not provide %2$@ because %3$@ already does." = "%3$@ 已提供 %2$@,因此不再使用 %1$@ 提供的版本。"; +"Committing…" = "正在提交…"; +"Rolling back…" = "正在回滚…"; +"Updating…" = "正在更新…"; +"Marking as resolved…" = "正在标记为已解决…"; +"Cleaning up…" = "正在清理…"; +"Moving…" = "正在移动…"; +"Wait for the running version control action to finish." = "请等待正在执行的版本控制操作完成。"; +"This working copy is no longer available. Refresh and try again." = "此工作副本已不可用。请刷新后重试。"; +"Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again." = "认证失败。Lithe 暂时无法询问凭据:请先用命令行客户端登录一次并保存凭据,然后重试。"; +"The working copy is out of date. Update it, then commit again." = "工作副本已过期。请先更新,再重新提交。"; +"The action stopped on conflicted files. Resolve them, then try again." = "操作因冲突文件而停止。请先解决冲突,然后重试。"; +"The working copy is locked by an interrupted action. Run Cleanup, then try again." = "工作副本被中断的操作锁定。请先执行清理,然后重试。"; +"This working copy format is not supported. Upgrade it with the command-line client." = "不支持此工作副本格式。请用命令行客户端升级。"; +"The version control client could not be run. Check that it is installed." = "无法运行版本控制客户端。请检查它是否已安装。"; +"Lithe could not evaluate the version control data: %@" = "Lithe 无法解析版本控制数据:%@"; +"This version control provider does not support this action." = "此版本控制 Provider 不支持该操作。"; +"The version control provider failed to start." = "版本控制 Provider 启动失败。"; +"The version control provider stopped before it was ready." = "版本控制 Provider 在就绪前已停止。"; +"Version control provider" = "版本控制 Provider"; +"Missing" = "缺失"; +"Replaced" = "已替换"; +"Type changed" = "类型已更改"; +"Unversioned" = "未版本控制"; +"Ignored" = "已忽略"; +"Switched" = "已切换"; +"No revisions" = "没有修订版本"; +"More paths changed than are shown." = "更改的路径多于此处显示的数量。"; +"Open File" = "打开文件"; +"Binary files cannot be compared." = "无法比较二进制文件。"; +"The file is too large to compare." = "文件过大,无法比较。"; +"Base revision" = "基线版本"; +"Roll back the selected changes?" = "回滚选中的更改?"; +"Local edits to these files are lost." = "这些文件的本地修改将会丢失。"; +"Mark as Resolved" = "标记为已解决"; +"Clean Up Working Copy" = "清理工作副本"; +"More Actions" = "更多操作"; +"Working copy" = "工作副本"; +"%@ holds the metadata of several version control systems. Choose the one that manages it." = "%@ 包含多个版本控制系统的元数据。请选择管理它的版本控制系统。"; +"The workspace" = "工作区"; +"The workspace is too large to search completely; some working copies may be missing." = "工作区过大,无法完整搜索;可能有工作副本未被识别。"; +"There are too many changes to list; some are not shown." = "更改过多,部分未显示。"; +"Detecting working copies…" = "正在识别工作副本…"; +"Reading changes…" = "正在读取更改…"; +"No %@ working copy was detected in this workspace." = "此工作区中没有识别到 %@ 工作副本。"; +"Detect Again" = "重新识别"; +"Moved from %@" = "从 %@ 移动"; +"Tree conflict" = "树冲突"; +"Properties modified" = "属性已修改"; +"Locked in this working copy" = "已在此工作副本中锁定"; +"Locked by another user" = "已被其他用户锁定"; +"Show History" = "显示历史"; +"%lld files selected" = "已选择 %lld 个文件"; +"Committed revision %@." = "已提交修订版本 %@。"; +"Committed." = "已提交。"; +"Updated to revision %@." = "已更新到修订版本 %@。"; +"Updated." = "已更新。"; +"%lld files have conflicts." = "%lld 个文件存在冲突。"; +"The working copy was cleaned up." = "工作副本已清理。"; +"History of %@" = "%@ 的历史"; +"Show All" = "显示全部"; +"Restart Lithe to use %@." = "重启 Lithe 后即可使用 %@。"; "Instructions" = "指令"; "Global instructions" = "全局指令"; "Instruction Agent" = "指令所属 Agent"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index d96fd7e853..7819b18fc8 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -21,6 +21,8 @@ final class AppServices { /// Gives build providers that share a language server the language /// sessions; `nil` when the platform starts no build providers. var connectBuildProviderLanguageSessions: ((@escaping @MainActor () -> LanguageToolingSessionManager?) -> Void)? + /// Plugin VCS providers; `nil` when no installed plugin declares one. + var vcsProviders: VcsProvidersFeatureModel? let moduleRuntime: ModuleRuntime let pluginManager: any PluginManaging let pluginMarketplace: any PluginMarketplaceServicing diff --git a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift index a60d87d5a2..fa2107a67e 100644 --- a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift @@ -53,6 +53,9 @@ enum WorkspaceProjectionComposition { guard let feature = await model?.activateHistoryModule() else { return } await feature.relocateHistory(from: source, to: destination) }, + moveTrackedItem: { [weak model] source, destination in + await model?.vcsProvidersFeature?.move(from: source, to: destination) ?? false + }, relocateOpenDocuments: { [weak document] source, destination in document?.relocateOpenDocuments(from: source, to: destination) }, diff --git a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift new file mode 100644 index 0000000000..67f778377d --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift @@ -0,0 +1,862 @@ +import Combine +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// The user's choices for ambiguous directories, per workspace. +protocol VcsRootMappingStoring { + func mappings(workspaceURL: URL) -> [VcsRootMapping] + func setMappings(_ mappings: [VcsRootMapping], workspaceURL: URL) +} + +/// A working-copy root a plugin provider owns, with the status last read. +struct VcsRootState: Identifiable, Equatable { + let root: VcsRoot + let pluginID: String + /// The provider's display name, such as "Subversion". + let displayName: String + /// Built from the workspace URL, so paths below it match the project + /// tree's even when Core reports the canonical path. + let url: URL + let metadataPaths: [String] + /// Directory markers of the VCS, such as `.svn`; files below them are + /// metadata, not working files. + let metadataDirectories: Set + var negotiation: VcsProviderNegotiation? + var snapshot: VcsStatusSnapshot? + var isRefreshing = false + var failure: String? + + var id: String { root.id } + + /// The root folder's own name for the workspace root and an enclosing + /// working copy; the relative path for a nested one. + var title: String { + root.id.isEmpty || isEnclosing ? url.lastPathComponent : root.id + } + + /// Whether the root encloses the workspace (`..`, `../..`). Core's rule: + /// only IDs made entirely of `..` segments, so a folder inside the + /// workspace may still be named `..repo`. + var isEnclosing: Bool { Self.isEnclosingRootID(root.id) } + + nonisolated static func isEnclosingRootID(_ id: String) -> Bool { + !id.isEmpty && id.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { $0 == ".." } + } + + func supports(_ capability: VcsCapability) -> Bool { + negotiation?.supports(capability) == true + } + + /// Whether the change has a base revision worth comparing with. A copy + /// compares with its source; a new file has nothing to compare. + func comparesWithBase(_ change: VcsChange) -> Bool { + supports(.baseContent) && ![.unversioned, .ignored, .added].contains(change.status) + } +} + +/// One file compared with its base revision. +struct VcsFileDiff: Identifiable, Equatable { + let id = UUID() + let rootID: String + let path: String + let fileURL: URL + /// Empty for a file with no base; nil when it could not be shown. + let base: String? + let current: String? + let isBinary: Bool + let isTooLarge: Bool +} + +/// Status of project tree rows inside plugin-owned roots, by absolute path. +/// Rows below an unversioned or ignored directory share its status. +struct VcsTreeStatusProjection: Equatable { + private var statuses: [String: VcsChangeStatus] = [:] + private var containers: [String: VcsChangeStatus] = [:] + + init(_ statuses: [String: VcsChangeStatus] = [:]) { + self.statuses = statuses + containers = statuses.filter { $0.value == .unversioned || $0.value == .ignored } + } + + var isEmpty: Bool { statuses.isEmpty } + + func status(for url: URL) -> VcsChangeStatus? { + let path = url.standardizedFileURL.path + if let status = statuses[path] { return status } + guard !containers.isEmpty else { return nil } + var ancestor = Substring(path) + while let separator = ancestor.lastIndex(of: "/"), separator > ancestor.startIndex { + ancestor = ancestor[.. [VcsProviderPluginSource] + private let readFile: @MainActor (URL) -> Data? + private let fileExists: @Sendable (URL) -> Bool + private let sleep: @Sendable (Duration) async throws -> Void + private let now: @Sendable () -> ContinuousClock.Instant + private let metadataWatcherFactory: (any DirectoryWatcherFactory)? + /// Watchers of the roots' metadata directories, by path. + private var metadataWatchers: [String: any DirectoryChangeSource] = [:] + + /// Bumped whenever the workspace changes, so late results are dropped. + private var generation = 0 + /// Set when the project window's session ends; the feature then stays + /// empty, whatever late workspace load still asks for. + private var isShutDown = false + /// Every detected root, Git included, for ownership. + private var allRootIDs: [String] = [] + private var markerNames: Set = [] + private var detectionTask: Task? + private var refreshTasks: [String: Task] = [:] + private var pendingRefreshes: Set = [] + private var scheduledTask: Task? + private var scheduledRoots: Set = [] + private var scheduledDetection = false + private var activeRequests = 0 + private var metadataQuietUntil: ContinuousClock.Instant? + private var rootTreeStatuses: [String: [String: VcsChangeStatus]] = [:] + /// Bumped per root on every status read, so an older ownership answer + /// cannot replace a newer one. + private var treeRevisions: [String: Int] = [:] + + init( + planner: any VcsRootPlanning, + providers: any VcsProviderRequesting, + mappingStore: any VcsRootMappingStoring, + readFile: @escaping @MainActor (URL) -> Data? = { _ in nil }, + fileExists: @escaping @Sendable (URL) -> Bool = { _ in false }, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, + now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, + metadataWatcherFactory: (any DirectoryWatcherFactory)? = nil, + sources: @escaping @MainActor () -> [VcsProviderPluginSource] + ) { + self.planner = planner + self.providers = providers + self.mappingStore = mappingStore + self.readFile = readFile + self.fileExists = fileExists + self.sleep = sleep + self.now = now + self.metadataWatcherFactory = metadataWatcherFactory + self.sources = sources + } + + /// True when an enabled plugin declares a VCS provider, detected or not. + var hasProviders: Bool { !sources().isEmpty } + + func root(_ id: String) -> VcsRootState? { + roots.first { $0.id == id } + } + + // MARK: Workspace + + /// Forgets the previous workspace, stops its providers, and detects the + /// new one. After `shutdown()` it only clears. + func reset(workspaceURL: URL?) { + let workspaceURL = isShutDown ? nil : workspaceURL + generation += 1 + detectionTask?.cancel(); detectionTask = nil + refreshTasks.values.forEach { $0.cancel() } + refreshTasks = [:] + scheduledTask?.cancel(); scheduledTask = nil + pendingRefreshes = []; scheduledRoots = []; scheduledDetection = false + self.workspaceURL = workspaceURL?.standardizedFileURL + roots = []; ambiguous = []; issues = []; vcsNames = [:] + allRootIDs = []; markerNames = [] + isTruncated = false; isDetecting = false + runningOperation = nil; failure = nil + rootTreeStatuses = [:]; treeStatus = VcsTreeStatusProjection() + commitMessageDrafts = [:]; presentedDiff = nil + watchMetadata() + retainProviders() + detect() + } + + /// Ends the feature with its project session: stops every provider and + /// ignores later requests to load a workspace, such as one from a scan + /// that finishes while the window closes. A closed session's model is not + /// reused, so nothing turns it back on. + func shutdown() { + isShutDown = true + reset(workspaceURL: nil) + } + + /// Detects the roots again with the plugins enabled now. Status already + /// read is kept for roots that stay. + func detect() { + guard let workspaceURL else { return } + detectionTask?.cancel() + let sources = sources() + guard !sources.isEmpty else { + isDetecting = false + apply(nil, workspaceURL: workspaceURL, sources: [], mappings: []) + return + } + isDetecting = true + let mappings = mappingStore.mappings(workspaceURL: workspaceURL) + let planner = planner + let generation = generation + detectionTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + Result { + try planner.detectRoots( + workspaceURL: workspaceURL, manifests: sources.map(\.manifest), mappings: mappings + ) + } + }.value + guard let self, self.generation == generation, !Task.isCancelled else { return } + self.detectionTask = nil + self.isDetecting = false + switch result { + case .success(let detection): + self.apply(detection, workspaceURL: workspaceURL, sources: sources, mappings: mappings) + case .failure(let error): + self.apply(nil, workspaceURL: workspaceURL, sources: sources, mappings: []) + self.issues = [Self.message(for: error)] + } + } + } + + /// Stops what a now disabled plugin started and detects again. + func pluginsChanged() { + retainProviders() + detect() + } + + /// Chooses the VCS of an ambiguous directory and detects again. + func map(_ directoryID: String, to vcs: String) { + guard let workspaceURL else { return } + var mappings = mappingStore.mappings(workspaceURL: workspaceURL).filter { $0.id != directoryID } + mappings.append(VcsRootMapping(id: directoryID, vcs: vcs)) + mappingStore.setMappings(mappings, workspaceURL: workspaceURL) + detect() + } + + private func apply( + _ detection: VcsDetection?, + workspaceURL: URL, + sources: [VcsProviderPluginSource], + mappings: [VcsRootMapping] + ) { + let plugins = Dictionary( + (detection?.providers ?? []).compactMap { provider -> (String, VcsProviderDescriptor)? in + guard provider.origin.kind == "plugin", provider.origin.pluginID != nil else { return nil } + return (provider.vcs, provider) + }, + uniquingKeysWith: { first, _ in first } + ) + vcsNames = Dictionary( + (detection?.providers ?? []).map { ($0.vcs, $0.displayName) }, + uniquingKeysWith: { first, _ in first } + ) + markerNames = Set(plugins.values.flatMap { $0.markers.map(\.name) }) + let previous = Dictionary(uniqueKeysWithValues: roots.map { ($0.id, $0) }) + roots = (detection?.roots ?? []).compactMap { root in + guard let provider = plugins[root.vcs], let pluginID = provider.origin.pluginID else { return nil } + var state = VcsRootState( + root: root, pluginID: pluginID, displayName: provider.displayName, + url: Self.rootURL(id: root.id, workspaceURL: workspaceURL), + metadataPaths: provider.metadataPaths ?? [], + metadataDirectories: Set(provider.markers.filter { $0.type == "directory" }.map(\.name)) + ) + if let old = previous[root.id], old.root.vcs == root.vcs, old.pluginID == pluginID { + state.negotiation = old.negotiation + state.snapshot = old.snapshot + state.isRefreshing = old.isRefreshing + state.failure = old.failure + } + return state + } + allRootIDs = detection?.roots.map(\.id) ?? [] + ambiguous = detection?.ambiguous ?? [] + isTruncated = detection?.truncated ?? false + issues = (detection?.rejected ?? []).map { rejected in + let pluginID = rejected.index < sources.count ? sources[rejected.index].pluginID : "?" + return "\(pluginID): \(rejected.error.message)" + } + (detection?.conflicts ?? []).map { conflict in + String( + format: String(localized: "%1$@ does not provide %2$@ because %3$@ already does."), + conflict.pluginID, vcsNames[conflict.vcs] ?? conflict.vcs, conflict.by + ) + } + let kept = Set(roots.map(\.id)) + rootTreeStatuses = rootTreeStatuses.filter { kept.contains($0.key) } + publishTreeStatus() + if let unused = detection?.unusedMappings, !unused.isEmpty { + mappingStore.setMappings(mappings.filter { !unused.contains($0) }, workspaceURL: workspaceURL) + } + watchMetadata() + retainProviders() + roots.forEach { refresh($0.id) } + } + + /// Stops processes of other workspaces, disabled or replaced plugins, + /// and VCSs with no root left. + private func retainProviders() { + let workspaceURL = workspaceURL + let packages = Set(sources().map { [$0.pluginID, $0.packageURL.standardizedFileURL.path] }) + let used = Set(roots.map { [$0.pluginID, $0.root.vcs] }) + providers.retainProviders { target in + target.workspaceURL == workspaceURL + && packages.contains([target.pluginID, target.packageURL.standardizedFileURL.path]) + && used.contains([target.pluginID, target.vcs]) + } + } + + // MARK: File changes + + /// Watches the metadata directory of every root, such as `.svn` or + /// `../.svn`. The workspace watcher hides VCS metadata as the project + /// tree does and never sees a working copy that encloses the workspace, + /// so a commit or update from a terminal would otherwise go unnoticed. + /// Only the directory holding a declared metadata path is watched. + private func watchMetadata() { + guard let metadataWatcherFactory else { return } + var directories: [String: URL] = [:] + for root in roots { + for path in root.metadataPaths { + guard let first = path.split(separator: "/").first.map(String.init), + root.metadataDirectories.contains(first) else { continue } + let url = root.url.appendingPathComponent(first, isDirectory: true).standardizedFileURL + directories[url.path] = url + } + } + for (path, watcher) in metadataWatchers where directories[path] == nil { + watcher.stop() + metadataWatchers[path] = nil + } + for (path, url) in directories where metadataWatchers[path] == nil { + let watcher = metadataWatcherFactory.make( + configuration: DirectoryWatchConfiguration(workspaceRoot: url, gitContext: nil), + visibilityRules: .default + ) { [weak self] batch in + Task { @MainActor [weak self] in self?.metadataChanged(batch, in: url) } + } + metadataWatchers[path] = watcher + watcher.start() + } + } + + /// A metadata directory that lost track of its events, or went away, + /// counts as the marker changing, so the roots are detected again. + private func metadataChanged(_ batch: DirectoryChangeBatch, in directory: URL) { + guard metadataWatchers[directory.path] != nil else { return } + filesChanged( + batch.requiresFullRescan ? [directory] : batch.workspacePaths.map { URL(fileURLWithPath: $0) } + ) + } + + /// Workspace changes: the roots whose working files changed are read + /// again. The workspace watcher hides VCS markers, so a new working copy + /// shows only as created entries next to its marker. + func workspaceFilesChanged(_ changes: [WorkspaceFileChange]) { + filesChanged(changes.map(\.fileURL)) + detectNewRoots(near: changes.filter { $0.kind == .created }.map(\.fileURL)) + } + + /// Refreshes the roots whose files changed, shortly after the last + /// change. Metadata changes count only for the provider's declared + /// metadata paths, and not while the host's own requests write them. + /// A marker directory going away detects again. + private func filesChanged(_ urls: [URL]) { + guard workspaceURL != nil, hasProviders else { return } + let quiet = activeRequests > 0 || metadataQuietUntil.map { now() < $0 } == true + let byDepth = roots.sorted { $0.url.path.count > $1.url.path.count } + var affected: Set = [] + var redetect = false + for url in urls { + let path = url.standardizedFileURL.path + if markerNames.contains(url.lastPathComponent), !quiet { redetect = true } + guard let root = byDepth.first(where: { path.hasPrefix($0.url.path + "/") }) else { continue } + let relative = String(path.dropFirst(root.url.path.count + 1)) + let first = relative.split(separator: "/", maxSplits: 1).first.map(String.init) ?? relative + if root.metadataDirectories.contains(first) { + guard !quiet, root.metadataPaths.contains(where: { + relative == $0 || relative.hasPrefix($0 + "/") + }) else { continue } + } + affected.insert(root.id) + } + schedule(roots: affected, detect: redetect) + } + + /// Detects again when a created entry, or the directory holding it, has + /// a marker but is no known root or ambiguous directory, as after a + /// checkout or an update that fetched an external. The markers are + /// looked up off the main thread. + private func detectNewRoots(near created: [URL]) { + guard let workspaceURL, !created.isEmpty, !markerNames.isEmpty else { return } + let known = Set((allRootIDs + ambiguous.map(\.id)).map { Self.rootURL(id: $0, workspaceURL: workspaceURL).path }) + let workspacePath = workspaceURL.path + var directories: Set = [] + for url in created { + let url = url.standardizedFileURL + for path in [url.path, url.deletingLastPathComponent().path] + where (path == workspacePath || path.hasPrefix(workspacePath + "/")) && !known.contains(path) { + directories.insert(path) + } + } + guard !directories.isEmpty else { return } + let markers = markerNames + let fileExists = fileExists + let generation = generation + Task { [weak self] in + let found = await Task.detached(priority: .utility) { + directories.contains { directory in + markers.contains { fileExists(URL(fileURLWithPath: directory).appendingPathComponent($0)) } + } + }.value + guard found, let self, self.generation == generation else { return } + self.schedule(roots: [], detect: true) + } + } + + /// Refreshes `roots`, or detects again, shortly after the last change. + private func schedule(roots affected: Set, detect redetect: Bool) { + guard redetect || !affected.isEmpty else { return } + scheduledRoots.formUnion(affected) + scheduledDetection = scheduledDetection || redetect + scheduledTask?.cancel() + let sleep = sleep + let generation = generation + scheduledTask = Task { [weak self] in + do { try await sleep(Self.refreshDelay) } catch { return } + guard let self, self.generation == generation, !Task.isCancelled else { return } + let roots = self.scheduledRoots + let detects = self.scheduledDetection + self.scheduledRoots = []; self.scheduledDetection = false; self.scheduledTask = nil + // Detection refreshes every root it keeps. + if detects { self.detect() } else { roots.forEach(self.refresh) } + } + } + + // MARK: Status + + func refreshAll() { + roots.forEach { refresh($0.id) } + } + + /// Reads the root's status. A refresh asked for while one runs, or + /// while an action changes the root, runs once that ends. + func refresh(_ rootID: String) { + guard let index = index(of: rootID) else { return } + guard refreshTasks[rootID] == nil, runningOperation?.rootID != rootID else { + pendingRefreshes.insert(rootID) + return + } + roots[index].isRefreshing = true + let generation = generation + refreshTasks[rootID] = Task { [weak self] in + guard let self else { return } + var snapshot: VcsStatusSnapshot? + var failure: Error? + do { + snapshot = try await self.request( + .status(includeIgnored: false), rootID: rootID, timeout: Self.statusTimeout, + as: VcsStatusSnapshot.self + ) + } catch { + failure = error + } + guard self.generation == generation else { return } + self.refreshTasks[rootID] = nil + if let index = self.index(of: rootID) { + self.roots[index].isRefreshing = false + if let snapshot { + self.roots[index].snapshot = snapshot + self.roots[index].failure = nil + self.updateTreeStatus(rootID: rootID, snapshot: snapshot) + } else if let failure, !(failure is CancellationError) { + self.roots[index].failure = Self.message(for: failure) + } + } + if self.pendingRefreshes.remove(rootID) != nil { self.refresh(rootID) } + } + } + + /// Projects the root's changes onto the project tree. Changes that + /// belong to a nested root, such as a Git repository inside a working + /// copy, or that lie outside the workspace, are left out. + private func updateTreeStatus(rootID: String, snapshot: VcsStatusSnapshot) { + guard let workspaceURL else { return } + var paths: [String] = [] + var statuses: [VcsChangeStatus] = [] + for change in snapshot.changes where change.status != .ignored { + guard let path = Self.workspacePath(change.path, rootID: rootID, workspaceURL: workspaceURL), + !path.isEmpty else { continue } + paths.append(path) + statuses.append(change.hasTreeConflict ? .conflicted : change.status) + } + let revision = (treeRevisions[rootID] ?? 0) + 1 + treeRevisions[rootID] = revision + let planner = planner + let rootIDs = allRootIDs + let ambiguousIDs = ambiguous.map(\.id) + let generation = generation + Task { [weak self] in + let owners = await Task.detached(priority: .utility) { + try? planner.owners(roots: rootIDs, ambiguous: ambiguousIDs, paths: paths) + }.value + guard let self, self.generation == generation, self.treeRevisions[rootID] == revision, + self.index(of: rootID) != nil else { return } + var entries: [String: VcsChangeStatus] = [:] + for (index, path) in paths.enumerated() where owners.map({ $0[index] == rootID }) ?? true { + entries[workspaceURL.appendingPathComponent(path).standardizedFileURL.path] = statuses[index] + } + self.rootTreeStatuses[rootID] = entries + self.publishTreeStatus() + } + } + + private func publishTreeStatus() { + let merged = rootTreeStatuses.values.reduce(into: [String: VcsChangeStatus]()) { result, entries in + result.merge(entries) { current, _ in current } + } + let projection = VcsTreeStatusProjection(merged) + if projection != treeStatus { treeStatus = projection } + } + + // MARK: Actions + + /// Commits `paths`, root-relative. The provider adds selected + /// unversioned files and removes missing ones as part of the check-in. + func checkin(_ paths: [String], message: String, in rootID: String) async -> VcsCheckinResult? { + await perform(.checkin(paths: paths, message: message), in: rootID, + title: String(localized: "Committing…"), as: VcsCheckinResult.self) + } + + func rollback(_ paths: [String], in rootID: String) async -> Bool { + await perform(.rollback(paths: paths), in: rootID, + title: String(localized: "Rolling back…"), as: VcsEmptyResult.self) != nil + } + + func update(_ rootID: String) async -> VcsUpdateResult? { + await perform(.update(revision: nil), in: rootID, + title: String(localized: "Updating…"), as: VcsUpdateResult.self) + } + + func resolve(_ paths: [String], in rootID: String) async -> Bool { + await perform(.resolve(paths: paths), in: rootID, + title: String(localized: "Marking as resolved…"), as: VcsEmptyResult.self) != nil + } + + func cleanup(_ rootID: String) async -> Bool { + await perform(.cleanup, in: rootID, + title: String(localized: "Cleaning up…"), as: VcsEmptyResult.self) != nil + } + + func baseContent(_ path: String, in rootID: String) async throws -> VcsBaseContent { + try await request(.baseContent(path: path), rootID: rootID, timeout: Self.statusTimeout, as: VcsBaseContent.self) + } + + func history(in rootID: String, cursor: String?) async throws -> VcsHistoryPage { + try await request( + .history(cursor: cursor, limit: Self.historyPageSize), rootID: rootID, + timeout: Self.operationTimeout, as: VcsHistoryPage.self + ) + } + + func fileHistory(_ path: String, in rootID: String, cursor: String?) async throws -> VcsHistoryPage { + try await request( + .fileHistory(path: path, cursor: cursor, limit: Self.historyPageSize), rootID: rootID, + timeout: Self.operationTimeout, as: VcsHistoryPage.self + ) + } + + /// The change's base revision next to the file on disk. A file with no + /// base compares with nothing, and so does a deleted one. + func diff(for change: VcsChange, in rootID: String) async throws -> VcsFileDiff { + guard let root = root(rootID) else { throw CancellationError() } + let fileURL = root.url.appendingPathComponent(change.path).standardizedFileURL + var current: String? = "" + var isBinary = false + var isTooLarge = false + if ![.deleted, .missing].contains(change.status) { + let data = readFile(fileURL) + isTooLarge = (data?.count ?? 0) > Self.diffByteLimit + current = isTooLarge ? nil : data.flatMap { String(data: $0, encoding: .utf8) } + isBinary = data != nil && !isTooLarge && current == nil + } + var base: String? = "" + if ![.added, .unversioned, .ignored].contains(change.status), !isBinary, !isTooLarge { + let content = try await baseContent(change.path, in: rootID) + isBinary = content.binary + isTooLarge = (content.content?.utf8.count ?? 0) > Self.diffByteLimit + base = isBinary || isTooLarge ? nil : content.content ?? "" + } + return VcsFileDiff( + rootID: rootID, path: change.path, fileURL: fileURL, + base: base, current: isBinary || isTooLarge ? nil : current, + isBinary: isBinary, isTooLarge: isTooLarge + ) + } + + /// Loads the change's diff and presents it; a failure is reported. + func showDiff(_ change: VcsChange, in rootID: String) { + let generation = generation + Task { + do { + let diff = try await diff(for: change, in: rootID) + if self.generation == generation { presentedDiff = diff } + } catch is CancellationError { + } catch { + if self.generation == generation { report(Self.message(for: error), rootID: rootID) } + } + } + } + + /// The change the last status of `url`'s root lists for it, for the + /// project tree. + func change(at url: URL) -> (root: VcsRootState, change: VcsChange)? { + guard let (root, path) = rootPath(of: url), !path.isEmpty, + let change = root.snapshot?.changes.first(where: { $0.path == path }) else { return nil } + return (root, change) + } + + /// Moves a project item through the provider of the root that holds + /// both locations, so the VCS records the move. Returns false when no + /// such root tracks the item, or the provider refused; the caller then + /// moves it on disk, and a refusal is reported. + func move(from source: URL, to destination: URL) async -> Bool { + guard runningOperation == nil, let workspaceURL, + let (root, from) = rootPath(of: source), let (destinationRoot, to) = rootPath(of: destination), + root.id == destinationRoot.id, root.supports(.fileOperations), !from.isEmpty, !to.isEmpty, + !isUnversioned(source, in: root), !isUnversioned(destination.deletingLastPathComponent(), in: root), + let sourcePath = Self.workspaceRelativePath(source, workspaceURL: workspaceURL), + let destinationPath = Self.workspaceRelativePath(destination, workspaceURL: workspaceURL) + else { return false } + // Both ends must belong to the root by Core's rule: a nested root of + // another VCS, such as a Git repository, or an ambiguous directory + // with no VCS chosen takes its paths away from the working copy. + let planner = planner + let rootIDs = allRootIDs + let ambiguousIDs = ambiguous.map(\.id) + let generation = generation + let owners = await Task.detached(priority: .userInitiated) { + try? planner.owners(roots: rootIDs, ambiguous: ambiguousIDs, paths: [sourcePath, destinationPath]) + }.value + // An action that started meanwhile leaves the move to the disk, as + // one running before would, instead of reporting it as refused. + guard self.generation == generation, runningOperation == nil, + let owners, owners == [root.id, root.id] else { return false } + return await perform(.move(from: from, to: to), in: root.id, + title: String(localized: "Moving…"), as: VcsEmptyResult.self) != nil + } + + func dismissFailure() { + failure = nil + } + + func report(_ message: String, rootID: String?) { + failure = Failure(rootID: rootID, message: message) + } + + /// Runs an action that changes the working copy, then reads its status + /// again. A failure is reported through `failure`. + private func perform( + _ operation: VcsOperation, + in rootID: String, + title: String, + as type: Value.Type + ) async -> Value? { + guard runningOperation == nil else { + report(String(localized: "Wait for the running version control action to finish."), rootID: rootID) + return nil + } + runningOperation = RunningOperation(rootID: rootID, title: title) + failure = nil + let generation = generation + defer { + if self.generation == generation { + runningOperation = nil + refresh(rootID) + } + } + do { + return try await request(operation, rootID: rootID, timeout: Self.operationTimeout, as: type) + } catch { + if self.generation == generation, !(error is CancellationError) { + report(Self.message(for: error), rootID: rootID) + } + return nil + } + } + + /// Sends one operation to the root's provider and has Core validate the + /// reply. + private func request( + _ operation: VcsOperation, + rootID: String, + timeout: Duration, + as type: Value.Type + ) async throws -> Value { + guard let state = root(rootID), let target = target(for: state) else { + throw VcsProviderFailure( + code: "vcs.provider.unavailable", + message: String(localized: "This working copy is no longer available. Refresh and try again.") + ) + } + let generation = generation + activeRequests += 1 + defer { + activeRequests -= 1 + metadataQuietUntil = now() + Self.metadataQuietPeriod + } + let reply = try await providers.send(operation, root: state.root.path, to: target, timeout: timeout) + guard self.generation == generation else { throw CancellationError() } + if let index = index(of: rootID), roots[index].negotiation != reply.negotiation { + roots[index].negotiation = reply.negotiation + } + let planner = planner + return try await Task.detached(priority: .userInitiated) { + try planner.decodeReply( + operation, capabilities: reply.negotiation.capabilities, reply: reply.value, as: Value.self + ) + }.value + } + + private func target(for state: VcsRootState) -> VcsProviderTarget? { + guard let workspaceURL, + let source = sources().first(where: { $0.pluginID == state.pluginID }) else { return nil } + return VcsProviderTarget( + pluginID: source.pluginID, vcs: state.root.vcs, manifest: source.manifest, + packageURL: source.packageURL, workspaceURL: workspaceURL + ) + } + + private func index(of rootID: String) -> Int? { + roots.firstIndex { $0.id == rootID } + } + + // MARK: Paths + + /// The deepest plugin root holding `url`, with the root-relative path. + func rootPath(of url: URL) -> (VcsRootState, String)? { + let path = url.standardizedFileURL.path + let byDepth = roots.sorted { $0.url.path.count > $1.url.path.count } + for root in byDepth { + if path == root.url.path { return (root, "") } + if path.hasPrefix(root.url.path + "/") { + return (root, String(path.dropFirst(root.url.path.count + 1))) + } + } + return nil + } + + private static func workspaceRelativePath(_ url: URL, workspaceURL: URL) -> String? { + let path = url.standardizedFileURL.path + let base = workspaceURL.standardizedFileURL.path + return path.hasPrefix(base + "/") ? String(path.dropFirst(base.count + 1)) : nil + } + + private func isUnversioned(_ url: URL, in root: VcsRootState) -> Bool { + guard let status = treeStatus.status(for: url) else { return false } + return status == .unversioned || status == .ignored + } + + static func rootURL(id: String, workspaceURL: URL) -> URL { + id.isEmpty ? workspaceURL.standardizedFileURL : workspaceURL.appendingPathComponent(id).standardizedFileURL + } + + /// The workspace-relative path of a root-relative one; nil outside the + /// workspace, which only an enclosing working copy can report. + nonisolated static func workspacePath(_ path: String, rootID: String, workspaceURL: URL) -> String? { + guard VcsRootState.isEnclosingRootID(rootID) else { return rootID.isEmpty ? path : rootID + "/" + path } + let levels = rootID.split(separator: "/").count + let inside = workspaceURL.standardizedFileURL.pathComponents.suffix(levels).joined(separator: "/") + if path == inside { return "" } + return path.hasPrefix(inside + "/") ? String(path.dropFirst(inside.count + 1)) : nil + } + + // MARK: Messages + + /// The provider's message, after a hint for the failures the contract + /// names. + static func message(for error: Error) -> String { + guard let failure = error as? VcsProviderFailure else { return error.localizedDescription } + let hint: String? = switch failure.code { + case "vcs.authentication.failed": + String(localized: "Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again.") + case "vcs.outOfDate": + String(localized: "The working copy is out of date. Update it, then commit again.") + case "vcs.conflict": + String(localized: "The action stopped on conflicted files. Resolve them, then try again.") + case "vcs.workingCopy.locked": + String(localized: "The working copy is locked by an interrupted action. Run Cleanup, then try again.") + case "vcs.workingCopy.unsupported": + String(localized: "This working copy format is not supported. Upgrade it with the command-line client.") + case "vcs.tool.unavailable": + String(localized: "The version control client could not be run. Check that it is installed.") + default: + nil + } + guard let hint else { return failure.message } + return failure.message.isEmpty ? hint : hint + "\n" + failure.message + } +} diff --git a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift index bfef1676d8..4d84321261 100644 --- a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift @@ -39,6 +39,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel { notify: @escaping @MainActor @Sendable (String) -> Void, recordHistory: @escaping @MainActor @Sendable (URL, LocalHistoryReason) async -> Void, relocateHistory: @escaping @MainActor @Sendable (URL, URL) async -> Void, + moveTrackedItem: @escaping @MainActor @Sendable (URL, URL) async -> Bool = { _, _ in false }, relocateOpenDocuments: @escaping @MainActor @Sendable (URL, URL) -> Void, closeDocuments: @escaping @MainActor @Sendable (URL) -> Void, processExternalChanges: @escaping @MainActor @Sendable ([URL]) -> Bool, @@ -68,6 +69,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel { notify: notify, recordHistory: recordHistory, relocateHistory: relocateHistory, + moveTrackedItem: moveTrackedItem, relocateOpenDocuments: relocateOpenDocuments, closeDocuments: closeDocuments, processExternalChanges: processExternalChanges, diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 0769932e3f..cc1a306c8d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2034,6 +2034,16 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let timeoutMilliseconds: Int } + private struct LspVcsRequest: Encodable { + let sessionId: String + let vcs: String + let operation: String + let root: String + let tool: String? + let arguments: ToolingJSONValue + let timeoutMilliseconds: Int + } + private struct LspFunctionRequest: Encodable { struct Reference: Encodable { let pluginID: String @@ -3923,6 +3933,32 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + /// Sends one VCS-provider operation. Core plans the method and params + /// and refuses the request unless the session negotiated a provider of + /// `vcs` with the operation's capability. + func lspVcsRequest( + sessionID: String, + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + executeResult( + command: "lsp.vcsRequest", + payload: LspVcsRequest( + sessionId: sessionID, + vcs: vcs, + operation: operation, + root: root, + tool: tool, + arguments: arguments, + timeoutMilliseconds: max(1, Self.milliseconds(timeout)) + ) + ) + } + /// Invokes a plugin function. Core refuses it unless the session belongs /// to `pluginID` and registered the function. func lspFunctionRequest( diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index ef25cf02f0..d155dc9268 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -122,6 +122,20 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec )) } + func vcsProviderLaunchPlan( + manifest: Data, + vcs: String, + request: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan { + try launchPlan(LaunchPlanPayload( + manifest: try Self.json(manifest), + languageID: nil, + buildSystemID: nil, + vcsProviderID: vcs, + request: LaunchRequest(request) + )) + } + private func launchPlan(_ payload: LaunchPlanPayload) throws -> DeclarativeLanguageServerLaunchPlan { let outcome: Outcome = try execute( "plugins.declarative.languageServerLaunchPlan", @@ -162,6 +176,14 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec } ) result.workspaceConfiguration = plan.workspaceConfiguration + result.vcsProvider = plan.vcsProvider.map { + .init( + vcs: $0.vcs, + toolCommands: $0.tool?.commands ?? [], + toolValidationArguments: $0.tool?.validationArguments ?? [], + mayUseNetwork: $0.mayUseNetwork ?? false + ) + } return result } @@ -370,6 +392,18 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let requiredDirectories: [String] let requiredPackagePaths: [String] let buildProvider: BuildProvider? + let vcsProvider: VcsProvider? + + struct VcsProvider: Decodable { + struct Tool: Decodable { + let commands: [String] + let validationArguments: [String]? + } + + let vcs: String + let tool: Tool? + let mayUseNetwork: Bool? + } struct BuildProvider: Decodable { let buildSystemID: String @@ -405,6 +439,7 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let manifest: ToolingJSONValue let languageID: String? let buildSystemID: String? + var vcsProviderID: String? = nil let request: LaunchRequest } diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 79ad280000..c03d81498b 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -218,6 +218,23 @@ extension RustCoreBridge: LanguageServerRuntimeCore { .mapError(Self.runtimeFailure) } + func requestVcsProvider( + sessionID: String, + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + lspVcsRequest( + sessionID: sessionID, vcs: vcs, operation: operation, root: root, tool: tool, + arguments: arguments, timeout: timeout + ) + .map { LanguageServerRuntimeOperation(operationID: $0.operationId) } + .mapError(Self.runtimeFailure) + } + func startPluginLanguageServer( pluginID: String, providerID: String, diff --git a/macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift b/macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift new file mode 100644 index 0000000000..d111517621 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift @@ -0,0 +1,151 @@ +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// Typed `vcs.*` operations. Rust Core owns root detection, ownership, and +/// reply validation for both products; this adapter only translates +/// payloads and outcomes. +struct RustVcsOperations: VcsRootPlanning { + let core: RustCoreBridge + + func detectRoots( + workspaceURL: URL, + manifests: [Data], + mappings: [VcsRootMapping] + ) throws -> VcsDetection { + let outcome: Outcome = try execute( + "vcs.detectRoots", + payload: DetectPayload( + workspaceRoot: workspaceURL.standardizedFileURL.path, + plugins: try manifests.map { DetectPayload.Plugin(manifest: try Self.json($0)) }, + mappings: mappings + ) + ) + return outcome.value.detection + } + + func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] { + guard !paths.isEmpty else { return [] } + let outcome: Outcome = try execute( + "vcs.ownerOf", + payload: OwnerPayload(roots: roots, ambiguous: ambiguous, paths: paths) + ) + return outcome.value.owners.map(\.root) + } + + func decodeReply( + _ operation: VcsOperation, + capabilities: [String: Int], + reply: ToolingJSONValue, + as type: Value.Type + ) throws -> Value { + let outcome: Outcome> = try execute( + "vcs.decodeReply", + payload: DecodePayload( + operation: operation.name, + capabilities: capabilities, + request: operation.arguments, + reply: reply + ) + ) + return outcome.value.result + } + + private func execute( + _ command: String, + payload: Payload + ) throws -> Outcome { + let outcome: Outcome + do { + outcome = try core.executeResult(command: command, payload: payload).get() + } catch { + throw DeclarativePluginRejection( + code: "vcs.provider.failed", + message: String(localized: "Lithe could not evaluate the version control data: \(error.localizedDescription)") + ) + } + if let rejection = outcome.error { + throw VcsProviderFailure(code: rejection.code, message: rejection.message) + } + return outcome + } + + private static func json(_ data: Data) throws -> ToolingJSONValue { + do { + return try JSONDecoder().decode(ToolingJSONValue.self, from: data) + } catch { + throw DeclarativePluginRejection(code: "plugin.manifest.invalid", message: "plugin.json is not valid JSON.") + } + } + + // MARK: Wire types + + private struct DetectPayload: Encodable { + struct Plugin: Encodable { + let manifest: ToolingJSONValue + } + + let workspaceRoot: String + let plugins: [Plugin] + let mappings: [VcsRootMapping] + } + + private struct DetectionResult: Decodable { + let detection: VcsDetection + } + + private struct OwnerPayload: Encodable { + let roots: [String] + let ambiguous: [String] + let paths: [String] + } + + private struct OwnersResult: Decodable { + struct Owner: Decodable { + let root: String? + } + + let owners: [Owner] + } + + private struct DecodePayload: Encodable { + let operation: String + let capabilities: [String: Int] + let request: ToolingJSONValue + let reply: ToolingJSONValue + } + + private struct DecodedReply: Decodable { + let result: Value + } + + private struct Rejection: Decodable { + let code: String + let message: String + } + + /// `{status: "ok", …}` with the value at the top level, or + /// `{status: "rejected", error}`. + private struct Outcome: Decodable { + let error: Rejection? + private let decodedValue: Value? + + var value: Value { decodedValue! } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if try container.decode(String.self, forKey: .status) == "rejected" { + error = try container.decode(Rejection.self, forKey: .error) + decodedValue = nil + } else { + error = nil + decodedValue = try Value(from: decoder) + } + } + + private enum CodingKeys: String, CodingKey { + case status, error + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index a931b5fcdd..cb72079ad8 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -201,6 +201,7 @@ extension AppModel { guard let workspaceURL else { return } runFeatureIfActive?.markDependencyFilesChanged(changes) handleBuildSystemFileChanges(changes) + handleVcsProviderFileChanges(changes) let maven = mavenFeatureIfActive let forwarded = changes.filter { change in if Self.isWorkspaceDependencyMetadata( diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift index 98ab3d9e0f..639ee91191 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -40,6 +40,7 @@ extension AppModel { do { try await services.pluginManager.uninstall(pluginID) buildSystemsFeatureIfActive?.pluginsChanged() + vcsProvidersFeature?.pluginsChanged() closeStalePluginWebPages() objectWillChange.send() } catch { @@ -72,6 +73,7 @@ extension AppModel { } if !appliedPluginIDs.isEmpty { buildSystemsFeatureIfActive?.pluginsChanged() + vcsProvidersFeature?.pluginsChanged() closeStalePluginWebPages() } objectWillChange.send() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index 8f4df738d3..d82c636dad 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -143,6 +143,7 @@ extension AppModel { ($0.url.standardizedFileURL, $0.text) }) ) + loadVcsProviders(at: target) guard let execution = await activateExecutionModule() else { return } guard isCurrentWorkspace(identity) else { return } loadBuildSystems(execution) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift new file mode 100644 index 0000000000..0b8d12a7ec --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift @@ -0,0 +1,22 @@ +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +@MainActor +extension AppModel { + /// Plugin VCS providers; nil when no installed plugin declares one. + var vcsProvidersFeature: VcsProvidersFeatureModel? { services.vcsProviders } + + /// Detects the workspace's plugin VCS roots once per workspace; later + /// changes arrive through `handleVcsProviderFileChanges`. + func loadVcsProviders(at workspaceURL: URL) { + guard let feature = vcsProvidersFeature, + feature.workspaceURL != workspaceURL.standardizedFileURL else { return } + feature.reset(workspaceURL: workspaceURL) + } + + func handleVcsProviderFileChanges(_ changes: [WorkspaceFileChange]) { + vcsProvidersFeature?.workspaceFilesChanged(changes) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 85a44c8256..d9e0808a35 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -711,6 +711,9 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { // closing its workspace. Revoke page requests before teardown can yield. services.pluginWebPages.closeAll() workbenchFeature.endLayoutSession() + // VCS providers too: their pending refreshes, or a scan still loading + // the workspace, would otherwise start a process for a closed window. + vcsProvidersFeature?.shutdown() await ideCapabilities.shutdown() shortcutSessionCoordinator?.shutdown() documentLanguageCoordinator?.stop() @@ -956,6 +959,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { tests: languageTestServiceIfActive, buildSystems: buildSystemsFeatureIfActive ) + vcsProvidersFeature?.reset(workspaceURL: nil) runWorkflowCoordinator.resetPendingAction() debugModuleCoordinator.resetFeature(genericDebugFeatureIfActive) debugBreakpointPresentation.reset() @@ -1022,6 +1026,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { tests: languageTestServiceIfActive, buildSystems: buildSystemsFeatureIfActive ) + vcsProvidersFeature?.reset(workspaceURL: nil) runWorkflowCoordinator.resetPendingAction() debugModuleCoordinator.resetFeature(genericDebugFeatureIfActive) debugBreakpointPresentation.reset() diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 7f7109edb7..1965db1d0b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -269,6 +269,16 @@ final class MacServiceContainer { core: rustCore, processRegistry: processRegistry ) + // Provider processes of plugin VCS providers, one per plugin, VCS, + // and workspace; one starts on the first request for a detected root. + let vcsProviderSessions = MacVcsProviderSessions( + launchResolver: pluginLaunchResolver, + tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), + versionChecker: declarativePluginOperations, + runtime: runtimeService, + core: rustCore, + processRegistry: processRegistry + ) let rustLanguageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) // Installation owns the process-backed language boundary even when a // package is disabled, quarantined, or failed to load. Only the active @@ -676,6 +686,7 @@ final class MacServiceContainer { // build systems' plugin list is a startup snapshot and does // not see the replacement. buildProviderSessions.stopProviders(ofPlugin: id.rawValue) + vcsProviderSessions.stopProviders(ofPlugin: id.rawValue) }, reviewPrograms: { [pluginMarketplaceEvaluator] manifest, id, version in try pluginMarketplaceEvaluator.installReview( @@ -790,6 +801,34 @@ final class MacServiceContainer { services.connectBuildProviderLanguageSessions = { sessions in buildProviderSessions.languageSessions = sessions } + let vcsProviderPlugins = pluginStartup.registeredPlugins.filter { !$0.descriptor.vcsProviders.isEmpty } + if !vcsProviderPlugins.isEmpty { + services.vcsProviders = VcsProvidersFeatureModel( + planner: RustVcsOperations(core: rustCore), + providers: vcsProviderSessions, + mappingStore: MacVcsRootMappingStore(store: store), + readFile: { url in try? fileStorage.readData(from: url, options: .mappedIfSafe) }, + fileExists: { url in fileStorage.fileExists(at: url) }, + metadataWatcherFactory: MacDirectoryWatcherFactory(), + sources: { + // Enablement is read on every detection, so turning a + // plugin off takes its roots away at once. + vcsProviderPlugins.compactMap { plugin -> VcsProviderPluginSource? in + let id = plugin.descriptor.id.rawValue + guard let snapshot = try? moduleRuntime.snapshot(for: .vcsProvidersExtension(id)), + snapshot.state != .disabled, + !snapshot.isQuarantined, + !snapshot.isSuppressedBySafeMode else { return nil } + return VcsProviderPluginSource( + pluginID: id, + manifest: plugin.descriptor.normalizedManifest, + packageURL: plugin.packageURL, + providers: plugin.descriptor.vcsProviders + ) + } + } + ) + } services.processExitCoordinator = ProcessExitCoordinator( core: RustProcessLifecycleDecider(core: rustCore), presenter: MacProcessExitConfirmationPresenter.shared @@ -879,10 +918,13 @@ final class MacServiceContainer { ) } } - // The build-systems module also carries the enabled state; the - // execution module reads it before handing the plugin to Rust Core. + // The build-systems and VCS-providers modules also carry the + // enabled state; the host reads it before handing the plugin to + // Rust Core. let workbench = descriptor.pluginManifest.modules.filter { - !$0.contributions.isEmpty || $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) + !$0.contributions.isEmpty + || $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) + || $0.manifest.id == .vcsProvidersExtension(descriptor.id.rawValue) }.map { declaration in ModuleFactory(manifest: declaration.manifest, contributions: declaration.contributions) { DeclarativeWorkbenchModule(declaration: declaration) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift index a4d5618ecb..8ed9c8537e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift @@ -53,6 +53,20 @@ struct MacPluginStateDirectories: Sendable { .appendingPathComponent(Self.workspaceDigest(workspaceRootURL), isDirectory: true) } + /// `${stateDir}` of one VCS provider process. + func vcsProviderStateDirectory(pluginID: String, vcs: String) -> URL { + pluginDirectory(pluginID: pluginID) + .appendingPathComponent("vcs-providers", isDirectory: true) + .appendingPathComponent(vcs, isDirectory: true) + } + + /// `${workspaceStateDir}` of one VCS provider. + func vcsProviderWorkspaceStateDirectory(pluginID: String, vcs: String, workspaceRootURL: URL) -> URL { + vcsProviderStateDirectory(pluginID: pluginID, vcs: vcs) + .appendingPathComponent("workspaces", isDirectory: true) + .appendingPathComponent(Self.workspaceDigest(workspaceRootURL), isDirectory: true) + } + static func workspaceDigest(_ workspaceRootURL: URL) -> String { SHA256.hash(data: Data(workspaceRootURL.standardizedFileURL.path.utf8)) .map { String(format: "%02x", $0) } @@ -209,6 +223,75 @@ struct MacPluginLanguageServerLaunchResolver { ) } if let languageID = provider.languageServer { return .languageServer(languageID) } + return .process(try providerProcess( + plan, request: request, packageURL: packageURL, + executesProjectCode: provider.executesProjectCode, mayUseNetwork: provider.mayUseNetwork, + settings: launchSettings + )) + } + + /// A VCS provider process with every package path verified and its + /// state directories created. The VCS client is resolved separately and + /// named in each request. + struct VcsProviderProcess: Equatable { + let process: BuildProviderProcess + let vcs: String + let toolCommands: [String] + let toolValidationArguments: [String] + } + + /// Expands the provider launch of one VCS for one workspace. Its state + /// directories belong to the provider, apart from the plugin's language + /// servers and build systems. + func resolveVcsProvider( + pluginID: String, + vcs: String, + manifest: Data, + packageURL: URL, + workspaceRootURL: URL + ) throws -> VcsProviderProcess { + let launchSettings = self.settingValues(forPlugin: pluginID, workspaceRootURL: workspaceRootURL) + let request = DeclarativeLanguageServerLaunchRequest( + pluginDirectory: packageURL.standardizedFileURL, + stateDirectory: directories.vcsProviderStateDirectory(pluginID: pluginID, vcs: vcs), + workspaceStateDirectory: directories.vcsProviderWorkspaceStateDirectory( + pluginID: pluginID, + vcs: vcs, + workspaceRootURL: workspaceRootURL + ), + architecture: Self.hostArchitecture, + settings: settings.values(forPlugin: pluginID), + projectSettings: settings.projectValues(forPlugin: pluginID, projectRoot: workspaceRootURL) + ) + let plan = try planner.vcsProviderLaunchPlan(manifest: manifest, vcs: vcs, request: request) + guard let provider = plan.vcsProvider, provider.vcs == vcs else { + throw DeclarativePluginRejection( + code: "plugin.launch.unavailable", + message: "Rust Core returned no VCS provider for \(vcs)." + ) + } + let process = try providerProcess( + plan, request: request, packageURL: packageURL, + executesProjectCode: false, mayUseNetwork: provider.mayUseNetwork, settings: launchSettings + ) + return VcsProviderProcess( + process: process, + vcs: vcs, + toolCommands: provider.toolCommands, + toolValidationArguments: provider.toolValidationArguments + ) + } + + /// The executable of a plan's own process, with its package paths + /// verified and state directories created. + private func providerProcess( + _ plan: DeclarativeLanguageServerLaunchPlan, + request: DeclarativeLanguageServerLaunchRequest, + packageURL: URL, + executesProjectCode: Bool, + mayUseNetwork: Bool, + settings launchSettings: [String: ToolingJSONValue] + ) throws -> BuildProviderProcess { try verifyPackagePaths(plan.requiredPackagePaths, packageURL: packageURL) let root = packageURL.standardizedFileURL let executable: BuildProviderProcess.Executable @@ -236,15 +319,15 @@ struct MacPluginLanguageServerLaunchResolver { } try fileManager.createDirectory(at: url, withIntermediateDirectories: true) } - return .process(BuildProviderProcess( + return BuildProviderProcess( executable: executable, arguments: plan.arguments, initializationOptions: plan.initializationOptions, runtimeTools: plan.runtimeRequirements.map(\.tool), - executesProjectCode: provider.executesProjectCode, - mayUseNetwork: provider.mayUseNetwork, + executesProjectCode: executesProjectCode, + mayUseNetwork: mayUseNetwork, settings: launchSettings - )) + ) } /// A declared host integration with its resources located in the package. diff --git a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift new file mode 100644 index 0000000000..0bbb584f51 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift @@ -0,0 +1,347 @@ +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// Provider processes of plugin VCS providers. Each process belongs to one +/// VCS of one plugin in one workspace and talks only to the host over its +/// own stdio channel, so no plugin can reach another plugin's provider. +/// +/// A process starts on the first request for a detected root of its VCS and +/// runs until `retainProviders` drops its target (a workspace switch, a +/// disabled plugin) or `stopProviders(ofPlugin:)` stops it before its +/// package is replaced or removed. A change of the plugin's settings +/// restarts it on the next request. Its writable state lives in the +/// provider's plugin state directory, never in the app bundle or the plugin +/// package. +@MainActor +final class MacVcsProviderSessions: VcsProviderRequesting { + struct UnavailableError: LocalizedError, Equatable { + let message: String + var errorDescription: String? { message } + } + + private let launchResolver: MacPluginLanguageServerLaunchResolver + private let tools: any BuildToolResolving + private let versionChecker: any LanguageServerRuntimeVersionChecking + private let runtime: any LanguageToolRuntimePort + private let core: any LanguageServerRuntimeCore + private weak var processRegistry: (any LanguageServerProcessRegistry)? + /// Ends the processes carrying a backend's token; `immediately` skips the + /// graceful stop. See `MacPluginBackendProcesses`. + private let endBackendProcesses: @MainActor (_ token: String, _ immediately: Bool) -> Void + private var running: [VcsProviderTarget: Running] = [:] + + /// One process, from its start until it is dropped. + private final class Running { + let settings: [String: ToolingJSONValue] + var task: Task? + var session: LanguageServerRuntimeSession? + + init(settings: [String: ToolingJSONValue]) { + self.settings = settings + } + } + + /// A ready session with what it negotiated and the client every request + /// names. + private struct Started { + let session: LanguageServerRuntimeSession + let negotiation: VcsProviderNegotiation + let toolPath: String? + } + + init( + launchResolver: MacPluginLanguageServerLaunchResolver, + tools: any BuildToolResolving, + versionChecker: any LanguageServerRuntimeVersionChecking, + runtime: any LanguageToolRuntimePort, + core: any LanguageServerRuntimeCore, + processRegistry: (any LanguageServerProcessRegistry)?, + endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in + MacPluginBackendProcesses.end( + token: token, + gracePeriod: immediately ? 0 : TimeInterval(MacProcessSupervisor.storedSettings().gracefulStopSeconds) + ) + } + ) { + self.launchResolver = launchResolver + self.tools = tools + self.versionChecker = versionChecker + self.runtime = runtime + self.core = core + self.processRegistry = processRegistry + self.endBackendProcesses = endBackendProcesses + } + + func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation { + try await started(for: target).negotiation + } + + func send( + _ operation: VcsOperation, + root: String, + to target: VcsProviderTarget, + timeout: Duration + ) async throws -> VcsProviderReply { + let started = try await started(for: target) + guard started.negotiation.supports(operation.capability) else { + throw VcsProviderFailure( + code: "vcs.capability.unsupported", + message: String(localized: "This version control provider does not support this action.") + ) + } + do { + let value = try await started.session.vcsRequest( + vcs: target.vcs, + operation: operation.name, + root: root, + tool: started.toolPath, + arguments: operation.arguments, + timeout: Self.seconds(timeout) + ) + return VcsProviderReply(value: value, negotiation: started.negotiation) + } catch let failure as LanguageServerRequestFailure { + throw VcsProviderFailure(code: failure.runtimeError.code, message: failure.runtimeError.message) + } + } + + func retainProviders(_ keep: (VcsProviderTarget) -> Bool) { + for (target, entry) in running where !keep(target) { + stop(entry, for: target) + } + } + + /// Stops every process of `pluginID`, in every workspace. The plugin + /// manager calls it before the package is replaced, rolled back, or + /// uninstalled, so no process keeps running the files it replaces. + func stopProviders(ofPlugin pluginID: String) { + retainProviders { $0.pluginID != pluginID } + } + + private func stop(_ entry: Running, for target: VcsProviderTarget) { + if running[target] === entry { running[target] = nil } + entry.task?.cancel() + entry.session?.stop() + } + + /// The target's running process, or a new one. A process that exited is + /// replaced, and so is one started with other setting values. + private func started(for target: VcsProviderTarget) async throws -> Started { + try Task.checkCancellation() + let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) + if let entry = running[target], let task = entry.task { + if entry.settings != settings || entry.session.map({ !$0.isRunning }) == true { + stop(entry, for: target) + } else { + let started = try await Self.waitForStart(task) + guard running[target] === entry else { throw CancellationError() } + return started + } + } + let entry = Running(settings: settings) + running[target] = entry + // The start is shared by every request that arrives meanwhile, so no + // single caller's cancellation cancels it; a failed start clears the + // entry itself. + let task = Task { @MainActor [weak self] () throws -> Started in + guard let self else { throw CancellationError() } + do { + return try await self.launch(target, entry: entry) + } catch { + if self.running[target] === entry { self.running[target] = nil } + throw error + } + } + entry.task = task + let started = try await Self.waitForStart(task) + guard running[target] === entry else { throw CancellationError() } + return started + } + + /// Waits for a shared start. A cancelled caller stops waiting at once, + /// while the start goes on for the other callers. + private static func waitForStart(_ task: Task) async throws -> Started { + let waiter = StartWaiter() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiter.wait(with: continuation) + if Task.isCancelled { + waiter.finish(.failure(CancellationError())) + return + } + Task { @MainActor in waiter.finish(await task.result) } + } + } onCancel: { + Task { @MainActor in waiter.finish(.failure(CancellationError())) } + } + } + + @MainActor + private final class StartWaiter { + private var continuation: CheckedContinuation? + + func wait(with continuation: CheckedContinuation) { + self.continuation = continuation + } + + func finish(_ result: Result) { + continuation?.resume(with: result) + continuation = nil + } + } + + private func launch(_ target: VcsProviderTarget, entry: Running) async throws -> Started { + let provider = try launchResolver.resolveVcsProvider( + pluginID: target.pluginID, + vcs: target.vcs, + manifest: target.manifest, + packageURL: target.packageURL, + workspaceRootURL: target.workspaceURL + ) + var (executableURL, environment) = try await resolveExecutable(provider.process, workspaceURL: target.workspaceURL) + let toolPath = try await resolveTool(provider, workspaceURL: target.workspaceURL) + try Task.checkCancellation() + guard running[target] === entry else { throw CancellationError() } + // Every process the backend starts, the VCS client included, inherits + // this token, so what it leaves behind can be ended when it is gone. + let backendToken = UUID().uuidString + environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + let session = LanguageServerRuntimeSession( + providerID: "vcsProvider:\(target.pluginID):\(target.vcs)", + executableURL: executableURL, + arguments: provider.process.arguments, + environment: environment, + initializationOptions: provider.process.initializationOptions, + initializeTimeout: 60, + core: core, + processRegistry: processRegistry, + moduleID: .vcsProvidersExtension(target.pluginID), + pluginID: target.pluginID + ) + let endBackendProcesses = endBackendProcesses + session.terminateProcess = { endBackendProcesses(backendToken, true) } + session.onReleased = { endBackendProcesses(backendToken, false) } + entry.session = session + try await waitUntilReady(session, rootURL: target.workspaceURL) + try Task.checkCancellation() + guard running[target] === entry else { throw CancellationError() } + // Core publishes the negotiation before the session is ready. A + // process that answered no provider, or another VCS than the + // manifest entry it was launched for, is stopped. + guard let negotiation = session.vcsNegotiation, negotiation.vcs == target.vcs else { + session.stop() + throw UnavailableError(message: String( + localized: "The plugin's version control provider did not start as declared. Update or reinstall the plugin." + )) + } + return Started(session: session, negotiation: negotiation, toolPath: toolPath) + } + + /// Starts the session and returns once `initialize` succeeded. + private func waitUntilReady(_ session: LanguageServerRuntimeSession, rootURL: URL) async throws { + var finished = false + defer { session.onStateChange = nil } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + func finish(_ result: Result) { + guard !finished else { return } + finished = true + continuation.resume(with: result) + } + session.onStateChange = { state in + switch state { + case .ready: + finish(.success(())) + case .failed(let failure): + finish(.failure(UnavailableError( + message: failure.message ?? String(localized: "The version control provider failed to start.") + ))) + case .stopped: + finish(.failure(UnavailableError( + message: String(localized: "The version control provider stopped before it was ready.") + ))) + case .startingProcess, .initializing, .stopping: + break + } + } + do { + try session.start(rootURL: rootURL, workspaceFingerprint: nil) + } catch { + finish(.failure(error)) + } + } + } + + /// The provider's own executable: a packaged file as it is, commands and + /// runtimes through the build tool discovery, so the provider sees the + /// same runtime settings and `PATH` as other plugin processes. + private func resolveExecutable( + _ process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess, + workspaceURL: URL + ) async throws -> (URL, [String: String]) { + let runtimes = process.runtimeTools.map { + BuildInvocation.RuntimeDependency(id: $0, hostManaged: true) + } + let invocation: BuildInvocation + var requirement: LanguageServerExecutableVersionRequirement? + switch process.executable { + case .file(let url): + return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + case .commands(let commands): + invocation = BuildInvocation(executables: commands.map { .command($0) }, runtimeDependencies: runtimes) + case .runtime(let tool, let versionRequirement): + requirement = versionRequirement + invocation = BuildInvocation( + executables: [.runtime(tool)], + runtimeDependencies: [BuildInvocation.RuntimeDependency( + id: tool, + hostManaged: true, + minimumVersion: versionRequirement?.minimumVersion, + versionArguments: versionRequirement?.probeArguments + )] + runtimes + ) + } + let versionChecker = versionChecker + let resolved = try await tools.resolve( + invocation, + displayName: String(localized: "Version control provider"), + workspaceURL: workspaceURL, + checkVersion: { [requirement] _, output in + guard let requirement else { return BuildVersionCheck(satisfied: true, version: nil) } + let check = try versionChecker.checkRuntimeVersion(requirement, output: output) + return BuildVersionCheck(satisfied: check.satisfied, version: check.version) + } + ) + return (resolved.executableURL, resolved.environment) + } + + /// The VCS client the provider runs, such as `svn`, found like any other + /// command. The provider itself reports a client that is too old; one + /// that cannot be found is `vcs.tool.unavailable`, as the contract names it. + private func resolveTool( + _ provider: MacPluginLanguageServerLaunchResolver.VcsProviderProcess, + workspaceURL: URL + ) async throws -> String? { + guard !provider.toolCommands.isEmpty else { return nil } + do { + let resolved = try await tools.resolve( + BuildInvocation(executables: provider.toolCommands.map { .command($0) }), + displayName: provider.toolCommands[0], + workspaceURL: workspaceURL, + checkVersion: { _, _ in BuildVersionCheck(satisfied: true, version: nil) } + ) + return resolved.executableURL.path + } catch is CancellationError { + throw CancellationError() + } catch { + throw VcsProviderFailure(code: "vcs.tool.unavailable", message: error.localizedDescription) + } + } + + private static func seconds(_ duration: Duration) -> TimeInterval { + let components = duration.components + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift new file mode 100644 index 0000000000..22beab8448 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift @@ -0,0 +1,33 @@ +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// The VCS chosen for each ambiguous directory, per workspace, in the app's +/// preferences. Detection drops mappings whose directory is no longer +/// ambiguous. +struct MacVcsRootMappingStore: VcsRootMappingStoring { + private static let keyPrefix = "Vcs.RootMappings." + let store: any KeyValueStore + + func mappings(workspaceURL: URL) -> [VcsRootMapping] { + guard let data = store.data(forKey: Self.key(workspaceURL)), + let mappings = try? JSONDecoder().decode([VcsRootMapping].self, from: data) else { return [] } + return mappings + } + + func setMappings(_ mappings: [VcsRootMapping], workspaceURL: URL) { + let key = Self.key(workspaceURL) + guard !mappings.isEmpty else { + store.set(nil, forKey: key) + return + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + store.set(try? encoder.encode(mappings.sorted { $0.id < $1.id }), forKey: key) + } + + private static func key(_ workspaceURL: URL) -> String { + keyPrefix + MacPluginStateDirectories.workspaceDigest(workspaceURL) + } +} diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index 0a65b1d76e..4ce0dbb736 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -830,6 +830,28 @@ enum PluginMarketplacePresentation { String(format: localized("Builds %@ projects", locale: locale), program.buildSystemID ?? ""), lines.joined(separator: "\n") ) + case .packagedVcsProvider, .systemVcsProvider, .runtimeVcsProvider: + let vcs = program.vcs ?? "" + var lines: [String] + let title: String + switch program.kind { + case .packagedVcsProvider: + title = String(format: localized("%@ provider (inside the plugin)", locale: locale), vcs) + lines = [serverDetail([program.path ?? ""])] + case .systemVcsProvider: + title = String(format: localized("%@ provider (installed on this Mac)", locale: locale), vcs) + lines = [serverDetail([program.commands.joined(separator: " | ")])] + default: + title = String(format: localized("%1$@ provider (runs with %2$@)", locale: locale), vcs, program.tool ?? "") + lines = [line([program.tool ?? ""] + program.runtimeArguments + [program.entry ?? ""] + program.arguments)] + } + if !program.vcsClientCommands.isEmpty { + lines.append(labeled("Client: %@", [program.vcsClientCommands.joined(separator: " | ")])) + } + if program.mayUseNetwork { + lines.append(localized("May connect to the repository server", locale: locale)) + } + return (title, lines.joined(separator: "\n")) case .unknown: // The full declaration stays behind the disclosure below so an // unfamiliar kind cannot push the sheet's buttons off screen. diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 556afae647..9c6fbcc9ce 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -327,6 +327,10 @@ private struct ActiveSessionChrome: View { ProjectLocalHistoryView(request: request, fontFamily: session.settings.editorFontFamily) .environmentObject(session) } + .modifier(VcsDiffPresentation( + feature: session.vcsProvidersFeature, fontFamily: session.settings.editorFontFamily, + openFile: { [weak session] in session?.openFile($0, allowsOutsideWorkspace: true) } + )) } diff --git a/macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift b/macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift new file mode 100644 index 0000000000..ee3b036c6e --- /dev/null +++ b/macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift @@ -0,0 +1,67 @@ +import LitheCoreContracts +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// How plugin VCS statuses look in the tool window and the project tree. +/// Letters follow the command-line clients most users know; colors follow +/// IDEA's file status colors, as the Git views do. +extension VcsChangeStatus { + var letter: String { + switch self { + case .modified: "M" + case .added, .copied: "A" + case .deleted: "D" + case .missing: "!" + case .renamed, .replaced: "R" + case .typeChanged: "T" + case .unversioned: "?" + case .ignored: "I" + case .conflicted: "C" + case .switched: "S" + } + } + + var title: String { + switch self { + case .modified: String(localized: "Modified") + case .added: String(localized: "Added") + case .copied: String(localized: "Copied") + case .deleted: String(localized: "Deleted") + case .missing: String(localized: "Missing") + case .renamed: String(localized: "Moved") + case .replaced: String(localized: "Replaced") + case .typeChanged: String(localized: "Type changed") + case .unversioned: String(localized: "Unversioned") + case .ignored: String(localized: "Ignored") + case .conflicted: String(localized: "Conflicted") + case .switched: String(localized: "Switched") + } + } + + /// File name color in change lists. + var listColor: Color { + switch self { + case .modified, .typeChanged, .switched, .replaced: LitheTheme.Commit.fileModified + case .added, .copied: LitheTheme.Commit.fileAdded + case .deleted, .missing: LitheTheme.Commit.fileDeleted + case .renamed: LitheTheme.Commit.fileRenamed + case .unversioned: LitheTheme.Commit.fileUntracked + case .ignored: LitheTheme.Commit.fileIgnored + case .conflicted: LitheTheme.Commit.fileConflicted + } + } + + /// Row color in the project tree, matching its Git colors. + var treeColor: Color { + switch self { + case .modified, .typeChanged, .switched, .replaced: LitheTheme.accent + case .added, .copied: LitheTheme.success + case .deleted, .missing: LitheTheme.error + case .renamed: LitheTheme.skill + case .unversioned: LitheTheme.Commit.fileUntracked + case .ignored: LitheTheme.Commit.fileIgnored + case .conflicted: LitheTheme.warning + } + } +} diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift new file mode 100644 index 0000000000..9508972e82 --- /dev/null +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift @@ -0,0 +1,266 @@ +import LitheCoreContracts +import LitheGitModule // DiffRow, the shared diff row model +import LitheLocalHistoryModule +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// Revisions of a root, or of one file in it, a page at a time. Each row +/// expands to the full message and the paths the revision changed. +struct VcsHistoryListView: View { + @ObservedObject var feature: VcsProvidersFeatureModel + let rootID: String + /// Root-relative file; nil lists the whole root. + let path: String? + @State private var entries: [VcsHistoryEntry] = [] + @State private var nextCursor: String? + @State private var isLoading = false + @State private var failure: String? + @State private var expanded: Set = [] + + var body: some View { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(entries) { entry in + row(entry) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + if let failure { + LitheBanner(status: .error, layout: .editor) { + Text(verbatim: failure).lineLimit(6).textSelection(.enabled) + Button(String(localized: "Retry")) { Task { await load() } } + } + } else if isLoading { + ProgressView().controlSize(.small).frame(maxWidth: .infinity).padding(12) + } else if nextCursor != nil { + Button(String(localized: "Load More")) { Task { await load() } } + .buttonStyle(.link) + .frame(maxWidth: .infinity) + .padding(12) + } else if entries.isEmpty { + Text("No revisions") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity) + .padding(20) + } + } + } + .litheWorkbenchSurface(LitheTheme.sidebar) + .task { await load() } + } + + private func row(_ entry: VcsHistoryEntry) -> some View { + let isExpanded = expanded.contains(entry.id) + let firstLine = entry.message.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? "" + return VStack(alignment: .leading, spacing: 3) { + Button { + if isExpanded { expanded.remove(entry.id) } else { expanded.insert(entry.id) } + } label: { + HStack(spacing: 6) { + LitheIDEAIcon(resourcePath: isExpanded ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", + size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) + Text(verbatim: entry.revision.display) + .font(LitheTheme.uiFont(size: 12, weight: .semibold, design: .monospaced)) + if let author = entry.author { + Text(verbatim: author).foregroundStyle(LitheTheme.secondaryText).lineLimit(1) + } + Spacer(minLength: 4) + if let date = entry.date { + Text(verbatim: Self.displayDate(date)) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + Text(verbatim: isExpanded ? entry.message : firstLine) + .lineLimit(isExpanded ? nil : 1) + .textSelection(.enabled) + .padding(.leading, 22) + if isExpanded { + VStack(alignment: .leading, spacing: 2) { + ForEach(entry.changes, id: \.self) { change in + HStack(spacing: 6) { + Text(verbatim: change.status.letter) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(change.status.listColor) + Text(verbatim: change.oldPath.map { "\(change.path) ← \($0)" } ?? change.path) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + if entry.changesTruncated { + Text("More paths changed than are shown.") + .foregroundStyle(LitheTheme.secondaryText) + } + } + .font(LitheTheme.uiFont(size: 11)) + .padding(.leading, 22) + } + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + + private func load() async { + guard !isLoading else { return } + isLoading = true + failure = nil + defer { isLoading = false } + do { + let page = if let path { + try await feature.fileHistory(path, in: rootID, cursor: nextCursor) + } else { + try await feature.history(in: rootID, cursor: nextCursor) + } + let known = Set(entries.map(\.id)) + entries += page.entries.filter { !known.contains($0.id) } + nextCursor = page.nextCursor + } catch is CancellationError { + } catch { + failure = VcsProvidersFeatureModel.message(for: error) + } + } + + /// Providers send ISO 8601; anything else is shown as sent. + static func displayDate(_ value: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date.formatted(date: .abbreviated, time: .shortened) } + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: value) { return date.formatted(date: .abbreviated, time: .shortened) } + return value + } +} + +/// Presents the diff a window's VCS feature asks for, from its tool window +/// or its project tree. +struct VcsDiffPresentation: ViewModifier { + let feature: VcsProvidersFeatureModel? + let fontFamily: String + let openFile: (URL) -> Void + + func body(content: Content) -> some View { + if let feature { + content.modifier(Presenter(feature: feature, fontFamily: fontFamily, openFile: openFile)) + } else { + content + } + } + + private struct Presenter: ViewModifier { + @ObservedObject var feature: VcsProvidersFeatureModel + let fontFamily: String + let openFile: (URL) -> Void + + func body(content: Content) -> some View { + content.sheet(item: $feature.presentedDiff) { diff in + VcsFileDiffSheet(diff: diff, fontFamily: fontFamily) { + feature.presentedDiff = nil + openFile(diff.fileURL) + } + } + } + } +} + +/// A change next to its base revision. Rows are built off the main thread; +/// a very long file is shown as two plain texts instead. +struct VcsFileDiffSheet: View { + /// Above this many lines in total, the line diff takes too long. + static let lineLimit = 20_000 + + let diff: VcsFileDiff + let fontFamily: String + let openFile: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var rows: [DiffRow]? + @State private var isOverLimit = false + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(verbatim: diff.path) + .font(LitheTheme.uiFont(size: 12, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(String(localized: "Open File"), action: openFile) + .disabled(diff.current == nil && !diff.isBinary && !diff.isTooLarge) + Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) + } + .padding(12) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + content + } + .frame(minWidth: 640, idealWidth: 960, minHeight: 400, idealHeight: 640) + .background(LitheTheme.editor) + .task(id: diff.id) { await buildRows() } + } + + @ViewBuilder + private var content: some View { + if diff.isBinary { + message(String(localized: "Binary files cannot be compared.")) + } else if diff.isTooLarge { + message(String(localized: "The file is too large to compare.")) + } else if isOverLimit { + HStack(alignment: .top, spacing: 0) { + textColumn(String(localized: "Base revision"), text: diff.base ?? "") + Rectangle().fill(LitheTheme.divider).frame(width: 1) + textColumn(String(localized: "Local"), text: diff.current ?? "") + } + } else if let rows { + DiffPaneView(rows: rows, fileExtension: (diff.path as NSString).pathExtension, + minimumWidth: 600, fontFamily: fontFamily) + } else { + ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func message(_ text: String) -> some View { + Text(verbatim: text) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func textColumn(_ title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: title).font(LitheTheme.uiFont(size: 12, weight: .semibold)) + ScrollView([.vertical, .horizontal]) { + Text(verbatim: text) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .litheScrollViewChrome() + } + .padding(8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func buildRows() async { + guard !diff.isBinary, !diff.isTooLarge else { return } + let base = diff.base ?? "" + let current = diff.current ?? "" + let limit = Self.lineLimit + let built = await Task.detached(priority: .userInitiated) { () -> [LocalHistoryDiffRow]? in + let lines = base.utf8.lazy.filter { $0 == 0x0A }.count + current.utf8.lazy.filter { $0 == 0x0A }.count + guard lines <= limit else { return nil } + return LocalHistoryDiffBuilder.rows(old: base, current: current) + }.value + guard !Task.isCancelled else { return } + if let built { + rows = built.map(DiffRow.init) + } else { + isOverLimit = true + } + } +} diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift new file mode 100644 index 0000000000..1673ce3fb6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift @@ -0,0 +1,730 @@ +import AppKit +import LitheCoreContracts +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +struct VcsProviderToolWindowActions { + let openFile: (URL) -> Void + let revealInFinder: (URL) -> Void + let copyPath: (URL) -> Void + let hide: () -> Void +} + +/// The tool window of one plugin VCS, laid out like IDEA's Commit window: +/// a Changes tab with the root's changes, a commit message, and the +/// actions the provider negotiated; a History tab with its revisions. +/// Every provider uses this view, so no VCS is named here; an action shows +/// only when the provider declares its capability. +struct VcsProviderToolWindowView: View { + @ObservedObject var feature: VcsProvidersFeatureModel + let title: String + let pluginID: String + let vcs: String + let actions: VcsProviderToolWindowActions + @Environment(\.controlActiveState) private var controlActiveState + @State private var tab = Tab.changes + @State private var listActive = false + @State private var selectedRootID: String? + /// Selected and explicitly included or excluded paths, root-relative, + /// of the shown root; a root change clears them. + @State private var selection: Set = [] + @State private var inclusion: [String: Bool] = [:] + @State private var collapsedGroups: Set = [] + @State private var message = "" + @State private var messageFocused = false + @State private var pendingRollback: [VcsChange] = [] + @State private var notice: String? + /// The file whose history the History tab shows; nil is the whole root. + @State private var historyPath: String? + + enum Tab: Hashable { case changes, history } + + enum Group: Hashable, CaseIterable { + case conflicts, changes, unversioned + + var title: String { + switch self { + case .conflicts: String(localized: "Conflicts") + case .changes: String(localized: "Changes") + case .unversioned: String(localized: "Unversioned Files") + } + } + } + + var body: some View { + VStack(spacing: 0) { + header + if let root { + toolbar(root) + rootBar(root) + } + banners + if let root { + switch tab { + case .changes: changesPane(root) + case .history: historyPane(root) + } + } else { + emptyState + } + } + .litheWorkbenchSurface(LitheTheme.editor) + .workbenchHoverTooltipScope() + .onAppear(perform: loadDraft) + .onChange(of: root?.id) { _ in + selection = []; inclusion = [:]; notice = nil; historyPath = nil + loadDraft() + } + .onChange(of: message) { text in + guard let root else { return } + feature.commitMessageDrafts[root.id] = text.isEmpty ? nil : text + } + .onChange(of: root?.snapshot?.changes) { changes in + let paths = Set((changes ?? []).map(\.path)) + selection.formIntersection(paths) + inclusion = inclusion.filter { paths.contains($0.key) } + } + .confirmationDialog( + String(localized: "Roll back the selected changes?"), + isPresented: Binding(get: { !pendingRollback.isEmpty }, set: { if !$0 { pendingRollback = [] } }), + titleVisibility: .visible + ) { + Button(String(localized: "Roll Back"), role: .destructive) { + let changes = pendingRollback + pendingRollback = [] + rollback(changes) + } + Button("Cancel", role: .cancel) { pendingRollback = [] } + } message: { + Text(verbatim: pendingRollback.map(\.path).joined(separator: "\n") + "\n\n" + + String(localized: "Local edits to these files are lost.")) + } + } + + // MARK: Roots + + private var roots: [VcsRootState] { + feature.roots.filter { $0.pluginID == pluginID && $0.root.vcs == vcs } + } + + private var root: VcsRootState? { + let roots = roots + return roots.first { $0.id == selectedRootID } ?? roots.first + } + + private var ambiguous: [VcsAmbiguousDirectory] { + feature.ambiguous.filter { $0.vcs.contains(vcs) } + } + + private func loadDraft() { + message = root.flatMap { feature.commitMessageDrafts[$0.id] } ?? "" + } + + // MARK: Header and toolbar + + private var header: some View { + HStack(spacing: 8) { + Text(verbatim: title) + .font(LitheTheme.uiFont(size: LitheTheme.Commit.toolbarFontSize, weight: .semibold)) + .foregroundStyle(LitheTheme.toolWindowText) + .lineLimit(1) + .padding(.trailing, 4) + if root != nil { + tabButton(.changes, title: String(localized: "Changes")) + if root?.supports(.history) == true { + tabButton(.history, title: String(localized: "History")) + } + } + Spacer() + LitheSidebarHideButton(title: title, action: actions.hide) + } + .padding(.leading, 12) + .padding(.trailing, 10) + .frame(height: 41) + .background(LitheTheme.toolHeader) + .overlay(alignment: .bottom) { LitheToolWindowHeaderDivider() } + } + + private func tabButton(_ value: Tab, title: String) -> some View { + Button { + tab = value + if value == .changes { historyPath = nil } + } label: { + Text(verbatim: title) + .font(LitheTheme.uiFont(size: LitheTheme.Commit.toolbarFontSize, weight: .regular)) + .foregroundStyle(tab == value ? LitheTheme.primaryText : LitheTheme.secondaryText) + .padding(.horizontal, LitheTheme.Commit.tabItemHorizontalPadding) + .frame(height: 28) + } + .buttonStyle(.litheNoPress) + .modifier(LitheToolWindowTabStyle(isSelected: tab == value, isActive: listActive)) + } + + private func toolbar(_ root: VcsRootState) -> some View { + let busy = feature.runningOperation != nil + let selected = selectedChanges(root) + return ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + toolbarButton(String(localized: "Refresh"), icon: "expui/general/refresh.svg", + isEnabled: !feature.isDetecting && !root.isRefreshing) { + feature.detect() + } + if root.supports(.update) { + toolbarButton(String(localized: "Update Project"), icon: "expui/vcs/update.svg", isEnabled: !busy) { + update(root) + } + } + if tab == .changes { + if root.supports(.rollback) { + toolbarButton(String(localized: "Roll Back…"), icon: "expui/vcs/revert.svg", + isEnabled: !busy && !rollbackTargets(selected).isEmpty) { + pendingRollback = rollbackTargets(selected) + } + } + if root.supports(.baseContent) { + toolbarButton(String(localized: "Show Diff"), icon: "expui/vcs/diff.svg", + isEnabled: selected.count == 1) { + if let change = selected.first { showDiff(change, in: root) } + } + } + if root.supports(.conflicts) { + toolbarButton(String(localized: "Mark as Resolved"), icon: "expui/actions/checked.svg", + isEnabled: !busy && !conflicted(selected).isEmpty) { + resolve(conflicted(selected), in: root) + } + } + } + if root.supports(.cleanup) { + LitheMenu { + LitheContextMenuItem.action(String(localized: "Clean Up Working Copy"), localizesTitle: false, + isEnabled: !busy) { cleanup(root) } + } label: { + icon("expui/general/moreVertical.svg") + } + .litheToolbarIconButton() + .padding(.horizontal, 2) + .accessibilityLabel(Text("More Actions")) + .workbenchHoverHelp(Text("More Actions")) + } + } + .padding(.horizontal, 7) + .padding(.vertical, 5) + } + .frame(height: LitheTheme.Metrics.toolbarIconButtonSize + 12) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private func toolbarButton(_ help: String, icon path: String, isEnabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { icon(path) } + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.45) + .accessibilityLabel(Text(verbatim: help)) + .buttonStyle(LitheIconButtonStyle( + size: LitheTheme.Metrics.toolbarIconButtonSize, cornerRadius: 4, + hoverBackground: LitheTheme.toolbarHoverBackground, + pressedBackground: LitheTheme.toolbarPressedBackground + )) + .padding(.horizontal, 2) + .padding(.vertical, 1) + .workbenchHoverHelp(Text(verbatim: help)) + } + + private func icon(_ path: String) -> some View { + LitheIDEAIcon(resourcePath: path, size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) + } + + /// The shown root, with a picker when the workspace has several, and + /// where its working copy points. + private func rootBar(_ root: VcsRootState) -> some View { + HStack(spacing: 6) { + icon("expui/nodes/folder.svg") + if roots.count > 1 { + LitheMenu { + for candidate in roots { + LitheContextMenuItem.action( + candidate.title, localizesTitle: false, + checked: candidate.id == root.id, leadingCheckmark: true, + initiallySelected: candidate.id == root.id + ) { selectedRootID = candidate.id } + } + } label: { + HStack(spacing: 4) { + Text(verbatim: root.title).lineLimit(1) + icon("expui/general/chevronDown.svg") + } + } + .litheToolbarIconButton(isIconOnly: false) + .accessibilityLabel(Text("Working copy")) + } else { + Text(verbatim: root.title).lineLimit(1) + } + Spacer(minLength: 8) + if root.isRefreshing { ProgressView().controlSize(.mini) } + if let head = root.snapshot?.head { + Text(verbatim: Self.headSummary(head)) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + .help(head.location ?? "") + } + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 10) + .frame(height: 28) + .help(root.url.path) + } + + static func headSummary(_ head: VcsHead) -> String { + [head.revision?.display, head.branch].compactMap { $0 }.joined(separator: " · ") + } + + // MARK: Banners and empty state + + @ViewBuilder + private var banners: some View { + ForEach(feature.issues, id: \.self) { issue in + LitheBanner(status: .warning, layout: .editor) { + Text(verbatim: issue).lineLimit(3).textSelection(.enabled) + } + } + ForEach(ambiguous, id: \.id) { directory in + LitheBanner(status: .info, layout: .editor) { + Text(String(format: String(localized: "%@ holds the metadata of several version control systems. Choose the one that manages it."), + directory.id.isEmpty ? String(localized: "The workspace") : directory.id)) + HStack(spacing: 6) { + ForEach(directory.vcs, id: \.self) { candidate in + Button(feature.vcsNames[candidate] ?? candidate) { feature.map(directory.id, to: candidate) } + } + } + } + } + if feature.isTruncated { + LitheBanner(status: .warning, layout: .editor) { + Text("The workspace is too large to search completely; some working copies may be missing.") + } + } + if let running = feature.runningOperation, roots.contains(where: { $0.id == running.rootID }) { + LitheBanner(status: .info, layout: .editor, isProgress: true) { + Text(verbatim: running.title) + } + } + if let failure = feature.failure, failure.rootID.map({ id in roots.contains { $0.id == id } }) ?? true { + LitheBanner(status: .error, layout: .editor, dismiss: feature.dismissFailure) { + Text(verbatim: failure.message).lineLimit(6).textSelection(.enabled) + } + .id(failure.id) + } + if let root, let message = root.failure { + LitheBanner(status: .error, layout: .editor) { + Text(verbatim: message).lineLimit(6).textSelection(.enabled) + Button(String(localized: "Retry")) { feature.refresh(root.id) } + } + } + if let root, root.snapshot?.truncated == true { + LitheBanner(status: .warning, layout: .editor) { + Text("There are too many changes to list; some are not shown.") + } + } + if let notice { + LitheBanner(status: .success, layout: .editor, dismiss: { self.notice = nil }) { + Text(verbatim: notice) + } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + if feature.isDetecting { + ProgressView().controlSize(.small) + Text("Detecting working copies…") + } else { + Text(String(format: String(localized: "No %@ working copy was detected in this workspace."), title)) + .multilineTextAlignment(.center) + Button(String(localized: "Detect Again")) { feature.detect() } + } + } + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: Changes + + private func changesPane(_ root: VcsRootState) -> some View { + VStack(spacing: 0) { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + let groups = Self.groups(root.snapshot?.changes ?? []) + if root.snapshot == nil, root.isRefreshing { + // The first read also starts the provider process, + // which can take a few seconds. + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Reading changes…") + } + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else if groups.isEmpty, root.snapshot != nil { + Text("No local changes") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } + ForEach(groups, id: \.group) { entry in + groupHeader(entry.group, changes: entry.changes, root: root) + if !collapsedGroups.contains(entry.group) { + ForEach(entry.changes, id: \.path) { change in + changeRow(change, group: entry.group, root: root) + } + } + } + } + .padding(.horizontal, LitheTheme.Tree.horizontalInset) + .padding(.vertical, LitheTheme.Tree.verticalInset) + } + .litheWorkbenchSurface(LitheTheme.sidebar) + .background(LitheToolWindowActivityTracker(isActive: $listActive)) + if root.supports(.checkin) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + commitArea(root) + } + } + } + + struct GroupedChanges { + let group: Group + let changes: [VcsChange] + } + + /// Conflicts first, as IDEA lists them; ignored files never reach the + /// list because status asks for none. + static func groups(_ changes: [VcsChange]) -> [GroupedChanges] { + var buckets: [Group: [VcsChange]] = [:] + for change in changes where change.status != .ignored { + buckets[group(of: change), default: []].append(change) + } + return Group.allCases.compactMap { group in + buckets[group].map { GroupedChanges(group: group, changes: $0.sorted { $0.path < $1.path }) } + } + } + + static func group(of change: VcsChange) -> Group { + if change.status == .conflicted || change.hasTreeConflict { return .conflicts } + return change.status == .unversioned ? .unversioned : .changes + } + + /// IDEA includes every versioned change and leaves new files out; a + /// conflict cannot be committed until it is resolved. An ignored file is + /// never listed, so it is never committed either. + static func isIncludedByDefault(_ change: VcsChange) -> Bool { + change.status != .ignored && group(of: change) == .changes + } + + private func isIncluded(_ change: VcsChange) -> Bool { + guard change.status != .ignored, Self.group(of: change) != .conflicts else { return false } + return inclusion[change.path] ?? Self.isIncludedByDefault(change) + } + + private func groupHeader(_ group: Group, changes: [VcsChange], root: VcsRootState) -> some View { + let collapsed = collapsedGroups.contains(group) + let included = changes.filter(isIncluded).count + return HStack(spacing: LitheTheme.Tree.iconTextGap) { + Button { + if collapsed { collapsedGroups.remove(group) } else { collapsedGroups.insert(group) } + } label: { + icon(collapsed ? "expui/general/chevronRight.svg" : "expui/general/chevronDown.svg") + } + .buttonStyle(.litheNoPress) + .accessibilityLabel(Text(collapsed ? "Expand" : "Collapse")) + if group != .conflicts && root.supports(.checkin) { + GitChangeInclusionCheckbox( + state: included == 0 ? .off : included == changes.count ? .on : .mixed, + accessibilityTitle: String(localized: "Include in commit") + ) { + let include = included < changes.count + for change in changes { inclusion[change.path] = include } + } + } + Text(verbatim: group.title) + Text(verbatim: "\(changes.count)") + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .litheTreeRow() + } + + private func changeRow(_ change: VcsChange, group: Group, root: VcsRootState) -> some View { + let url = root.url.appendingPathComponent(change.path) + let isSelected = selection.contains(change.path) + let name = (change.path as NSString).lastPathComponent + let directory = (change.path as NSString).deletingLastPathComponent + let detail = change.oldPath.map { "← " + $0 } ?? directory + return HStack(spacing: LitheTheme.Tree.iconTextGap) { + if root.supports(.checkin) { + GitChangeInclusionCheckbox( + state: isIncluded(change) ? .on : .off, + accessibilityTitle: String(localized: "Include in commit") + ) { + inclusion[change.path] = !isIncluded(change) + } + .disabled(group == .conflicts) + } + LitheIcon(kind: LitheIcons.kind(for: url, isDirectory: false), size: LitheTheme.Tree.iconSize) + Text(verbatim: name) + .foregroundStyle(change.status.listColor) + .lineLimit(1) + .layoutPriority(1) + if !detail.isEmpty { + Text(verbatim: detail) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 4) + if change.flags.contains("locked") || change.flags.contains("lockedByOther") { + icon("expui/general/locked.svg") + } + Text(verbatim: change.hasTreeConflict ? "C" : change.status.letter) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(change.status.listColor) + .padding(.trailing, 4) + } + .padding(.leading, LitheTheme.Tree.indent) + .litheTreeRow(isSelected: isSelected, isFocused: listActive && controlActiveState == .key) + .help(Self.rowHelp(change)) + .onTapGesture(count: 2) { open(change, in: root) } + .simultaneousGesture(TapGesture().onEnded { select(change) }) + .litheContextMenu(items: { contextMenu(for: change, root: root) }, onRightClick: { + if !selection.contains(change.path) { selection = [change.path] } + }) + // The combined row would otherwise only toggle its checkbox, so + // selecting, opening and including are separate actions. + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(verbatim: "\(change.path), \(change.status.title)")) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityAction { select(change) } + .accessibilityAction(named: Text(root.comparesWithBase(change) ? "Show Diff" : "Open File")) { + open(change, in: root) + } + .accessibilityAction(named: Text("Include in commit")) { + guard root.supports(.checkin), group != .conflicts else { return } + inclusion[change.path] = !isIncluded(change) + } + } + + static func rowHelp(_ change: VcsChange) -> String { + var parts = [change.path, change.status.title] + if let oldPath = change.oldPath { parts.append(String(format: String(localized: "Moved from %@"), oldPath)) } + if change.hasTreeConflict { parts.append(String(localized: "Tree conflict")) } + if change.flags.contains("propertiesModified") { parts.append(String(localized: "Properties modified")) } + if change.flags.contains("locked") { parts.append(String(localized: "Locked in this working copy")) } + if change.flags.contains("lockedByOther") { parts.append(String(localized: "Locked by another user")) } + return parts.joined(separator: "\n") + } + + private func select(_ change: VcsChange) { + if NSEvent.modifierFlags.contains(.command) { + if selection.contains(change.path) { selection.remove(change.path) } else { selection.insert(change.path) } + } else { + selection = [change.path] + } + } + + /// Double-click compares with the base when there is one; a new file + /// has nothing to compare, so it opens. + private func open(_ change: VcsChange, in root: VcsRootState) { + selection = [change.path] + if root.comparesWithBase(change) { + showDiff(change, in: root) + } else if ![.deleted, .missing].contains(change.status) { + actions.openFile(root.url.appendingPathComponent(change.path)) + } + } + + private func contextMenu(for change: VcsChange, root: VcsRootState) -> [LitheContextMenuItem] { + let selected = selectedChanges(root) + let url = root.url.appendingPathComponent(change.path) + let exists = ![.deleted, .missing].contains(change.status) + let busy = feature.runningOperation != nil + var items: [LitheContextMenuItem] = [] + if root.supports(.baseContent) { + items.append(.action(String(localized: "Show Diff"), localizesTitle: false, isEnabled: selected.count == 1) { + showDiff(change, in: root) + }) + } + items.append(.action(String(localized: "Open"), localizesTitle: false, isEnabled: exists) { actions.openFile(url) }) + if root.supports(.fileHistory) && change.status != .unversioned { + items.append(.action(String(localized: "Show History"), localizesTitle: false) { + historyPath = change.path + tab = .history + }) + } + items.append(.separator) + if root.supports(.conflicts) && !conflicted(selected).isEmpty { + items.append(.action(String(localized: "Mark as Resolved"), localizesTitle: false, isEnabled: !busy) { + resolve(conflicted(selected), in: root) + }) + } + if root.supports(.rollback) && !rollbackTargets(selected).isEmpty { + items.append(.action(String(localized: "Roll Back…"), localizesTitle: false, role: .destructive, isEnabled: !busy) { + pendingRollback = rollbackTargets(selected) + }) + } + items.append(.separator) + items.append(.action(String(localized: "Copy Path"), localizesTitle: false) { actions.copyPath(url) }) + items.append(.action(String(localized: "Reveal in Finder"), localizesTitle: false, isEnabled: exists) { + actions.revealInFinder(url) + }) + return items + } + + private func selectedChanges(_ root: VcsRootState) -> [VcsChange] { + (root.snapshot?.changes ?? []).filter { selection.contains($0.path) } + } + + /// Rolling back cannot bring back a file the VCS never had. + private func rollbackTargets(_ changes: [VcsChange]) -> [VcsChange] { + changes.filter { ![.unversioned, .ignored].contains($0.status) } + } + + private func conflicted(_ changes: [VcsChange]) -> [VcsChange] { + changes.filter { Self.group(of: $0) == .conflicts } + } + + // MARK: Commit + + private func commitArea(_ root: VcsRootState) -> some View { + let included = (root.snapshot?.changes ?? []).filter(isIncluded) + let canCommit = !included.isEmpty && !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && feature.runningOperation == nil + return VStack(spacing: 0) { + CommitMessageEditor(text: $message, focused: $messageFocused) + .frame(maxWidth: .infinity, minHeight: 64, maxHeight: 140, alignment: .topLeading) + .background { + RoundedRectangle(cornerRadius: LitheTheme.Commit.controlCornerRadius) + .fill(LitheTheme.editor) + .padding(1.5) + } + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Commit.controlCornerRadius) + .strokeBorder( + messageFocused ? LitheTheme.searchFieldFocusBorder : LitheTheme.searchFieldBorder, + lineWidth: messageFocused ? 2 : 1 + ) + .padding(messageFocused ? 0 : 1) + .allowsHitTesting(false) + } + .padding(.horizontal, LitheTheme.Commit.messageHorizontalGap) + .padding(.vertical, LitheTheme.Commit.messageVerticalGap) + HStack(spacing: 8) { + Button { + commit(included, in: root) + } label: { + HStack(spacing: 6) { + if feature.runningOperation?.rootID == root.id { ProgressView().controlSize(.mini) } + Text("Commit") + } + } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .disabled(!canCommit) + Text(String(format: String(localized: "%lld files selected"), included.count)) + .font(LitheTheme.uiFont(size: LitheTheme.Commit.metadataFontSize)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .padding(.leading, LitheTheme.Commit.contentInset) + .padding(.trailing, LitheTheme.Commit.contentInset) + .padding(.bottom, LitheTheme.ActionButton.spacing + 4) + } + .background(LitheTheme.toolHeader) + } + + /// A move commits both of its paths, so the source is never left behind. + static func commitPaths(_ changes: [VcsChange]) -> [String] { + var seen: Set = [] + return changes.flatMap { [$0.path] + ($0.oldPath.map { [$0] } ?? []) }.filter { seen.insert($0).inserted } + } + + private func commit(_ changes: [VcsChange], in root: VcsRootState) { + let text = message + let rootID = root.id + Task { + guard let result = await feature.checkin(Self.commitPaths(changes), message: text, in: rootID) else { return } + feature.commitMessageDrafts[rootID] = nil + if self.root?.id == rootID { + message = "" + inclusion = [:] + selection = [] + } + notice = result.revision.map { String(format: String(localized: "Committed revision %@."), $0.display) } + ?? String(localized: "Committed.") + } + } + + // MARK: Actions + + private func update(_ root: VcsRootState) { + notice = nil + Task { + guard let result = await feature.update(root.id) else { return } + var text = result.revision.map { String(format: String(localized: "Updated to revision %@."), $0.display) } + ?? String(localized: "Updated.") + if !result.conflicts.isEmpty { + text += " " + String(format: String(localized: "%lld files have conflicts."), result.conflicts.count) + } + notice = text + } + } + + private func rollback(_ changes: [VcsChange]) { + guard let root else { return } + let paths = Self.commitPaths(changes) + Task { + if await feature.rollback(paths, in: root.id) { selection.subtract(paths) } + } + } + + private func resolve(_ changes: [VcsChange], in root: VcsRootState) { + Task { _ = await feature.resolve(changes.map(\.path), in: root.id) } + } + + private func cleanup(_ root: VcsRootState) { + Task { + if await feature.cleanup(root.id) { notice = String(localized: "The working copy was cleaned up.") } + } + } + + private func showDiff(_ change: VcsChange, in root: VcsRootState) { + feature.showDiff(change, in: root.id) + } + + // MARK: History + + @ViewBuilder + private func historyPane(_ root: VcsRootState) -> some View { + VStack(spacing: 0) { + if let historyPath { + HStack(spacing: 6) { + Text(String(format: String(localized: "History of %@"), historyPath)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Button(String(localized: "Show All")) { self.historyPath = nil } + .buttonStyle(.link) + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 10) + .frame(height: 26) + } + VcsHistoryListView(feature: feature, rootID: root.id, path: historyPath) + .id([root.id, historyPath ?? ""]) + } + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift b/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift new file mode 100644 index 0000000000..26ce916d93 --- /dev/null +++ b/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift @@ -0,0 +1,95 @@ +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +extension WorkbenchModuleUIRegistry { + /// The tool windows of one plugin's VCS providers. Every provider gets + /// the same window; the contribution's visibility keys say which VCS it + /// lists, so no plugin or VCS name appears here. + static func vcsProviderRegistration(_ declaration: PluginModuleDeclaration) -> Registration { + let owner = declaration.manifest.id + var actions: [Action] = [] + var renderers: [Renderer] = [] + for window in declaration.contributions where window.kind == .toolWindow { + guard let actionID = window.actionID, let rendererID = window.rendererID, + let pluginID = window.visibility[DeclarativeVcsProvider.pluginIDKey], + let vcs = window.visibility[DeclarativeVcsProvider.vcsKey] else { continue } + let tool = WorkbenchFeatureModel.ToolWindow(rawValue: window.id) + // Detection already ran when the workspace opened; opening the + // window only makes sure it ran for this workspace. + let activate: @MainActor (AppModel) async -> Bool = { model in + do { + _ = try await model.services.moduleRuntime.activate(owner) + } catch { + if !Task.isCancelled && !(error is CancellationError) { model.showNotification(error.localizedDescription) } + return false + } + guard !Task.isCancelled, let workspaceURL = model.workspaceURL else { return false } + model.loadVcsProviders(at: workspaceURL) + return true + } + let toggle: @MainActor (AppModel) -> Void = { $0.performToolWindowContribution(window, owner: owner) } + actions.append(Action(id: actionID, perform: toggle, activateOwner: activate, state: { model in + ActionState( + isVisible: model.workspaceURL != nil, isEnabled: model.workspaceURL != nil, + isChecked: model.workbenchFeature.isVisible(tool), + isPending: model.contributionActivationTasks[window.id] != nil + ) + })) + for command in declaration.contributions where command.kind == .command + && command.visibility["toolWindowID"] == window.id { + guard let commandActionID = command.actionID, commandActionID != actionID else { continue } + actions.append(Action(id: commandActionID, perform: toggle, state: { model in + let available = model.workspaceURL != nil + let pending = model.contributionActivationTasks[window.id] != nil + return ActionState( + isVisible: available, isEnabled: available && !pending, + isChecked: model.workbenchFeature.isVisible(tool), isPending: pending + ) + })) + } + renderers.append(Renderer( + id: rendererID, ideaAssetPath: "expui/toolwindows/vcs@20x20.svg", + isVisible: { $0.workspaceURL != nil }, + isSelected: { $0.workbenchFeature.isVisible(tool) }, + content: { model in + // The feature exists when a VCS plugin was installed at + // launch; one installed later needs a restart. + guard let feature = model.vcsProvidersFeature else { + return AnyView(VcsProviderUnavailableView(title: window.title)) + } + return AnyView(VcsProviderToolWindowView( + feature: feature, title: window.title, pluginID: pluginID, vcs: vcs, + actions: VcsProviderToolWindowActions( + // A root may hold the workspace, so its files can lie outside it. + openFile: { [weak model] url in model?.openFile(url, allowsOutsideWorkspace: true) }, + revealInFinder: { [weak model] url in model?.revealProjectItemInFinder(url) }, + copyPath: { [weak model] url in model?.copyProjectItemPath(url, relative: false) }, + hide: { [weak model] in model?.workbenchFeature.setVisibility(tool, isVisible: false) } + ) + )) + }, + contentIdentity: { WorkbenchModuleUIRegistry.Renderer.featureIdentity($0.vcsProvidersFeature) }, + rightSidebarBehavior: .docked, + help: window.title + )) + } + return Registration(contributions: declaration.contributions, actions: actions, renderers: renderers) + } +} + +private struct VcsProviderUnavailableView: View { + let title: String + + var body: some View { + Text(String(format: String(localized: "Restart Lithe to use %@."), title)) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.editor) + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index cf3728d5ff..1fd032b60d 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -77,13 +77,18 @@ enum WorkbenchModuleUIComposition { static func registry(for catalog: ValidatedPluginCatalog, iconResources: [String: PluginUIIconResources] = [:]) throws -> WorkbenchModuleUIRegistry { let plugins = catalog.manifests.filter { $0.entrypoint.kind == .declarative } let declarations = plugins.flatMap(\.modules).filter { !$0.contributions.isEmpty } - // Build-system windows are derived from the declaration, not - // declared by the plugin, and share one host view. + // Build-system and VCS windows are derived from the declaration, + // not declared by the plugin, and each kind shares one host view. let buildSystemModules = Set(plugins.map { ModuleID.buildSystemsExtension($0.id.rawValue) }) + let vcsProviderModules = Set(plugins.map { ModuleID.vcsProvidersExtension($0.id.rawValue) }) var registry = try WorkbenchModuleUIRegistry(registrations: registrations + declarations.map { - buildSystemModules.contains($0.manifest.id) - ? WorkbenchModuleUIRegistry.buildSystemRegistration($0, iconResources: iconResources) - : WorkbenchModuleUIRegistry.declarativeRegistration($0, iconResources: iconResources) + if buildSystemModules.contains($0.manifest.id) { + return WorkbenchModuleUIRegistry.buildSystemRegistration($0, iconResources: iconResources) + } + if vcsProviderModules.contains($0.manifest.id) { + return WorkbenchModuleUIRegistry.vcsProviderRegistration($0) + } + return WorkbenchModuleUIRegistry.declarativeRegistration($0, iconResources: iconResources) }) registry.registerIcons(declarations.flatMap(\.contributions), resources: iconResources) return registry diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index 19b122df17..7b69b515b9 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -61,18 +61,13 @@ struct ProjectSidebarView: View { .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let root = model.rootNode { - ProjectSidebarTree( - model: model, - root: root, - rowHeight: rowHeight, - activeDocumentURL: model.activeDocument?.url, - gitStatus: ProjectGitStatusSnapshot(repositoryRoot: model.gitRepositoryRoot, projection: model.gitTreeStatusProjection), - directoryMarks: model.projectDirectoryMarks, - revealRequest: model.projectTreeRevealRequest, - stateSnapshot: treeState, - state: $treeState - ) - .equatable() + // Plugin VCS statuses publish on their own model, so only + // this reader observes it. + if let feature = model.vcsProvidersFeature { + VcsTreeStatusReader(feature: feature) { tree(root, vcsStatus: $0) } + } else { + tree(root, vcsStatus: VcsTreeStatusProjection()) + } } else if let error = model.workspaceLoadErrorMessage { VStack(spacing: 10) { Image(systemName: "folder.badge.questionmark") @@ -164,6 +159,23 @@ struct ProjectSidebarView: View { } } + private func tree(_ root: FileNode, vcsStatus: VcsTreeStatusProjection) -> some View { + ProjectSidebarTree( + model: model, + root: root, + rowHeight: rowHeight, + activeDocumentURL: model.activeDocument?.url, + gitStatus: ProjectGitStatusSnapshot( + repositoryRoot: model.gitRepositoryRoot, projection: model.gitTreeStatusProjection, vcs: vcsStatus + ), + directoryMarks: model.projectDirectoryMarks, + revealRequest: model.projectTreeRevealRequest, + stateSnapshot: treeState, + state: $treeState + ) + .equatable() + } + private var sidebarHeader: some View { HStack(spacing: 8) { LitheMenu { @@ -480,9 +492,18 @@ private struct ProjectTreeTaskID: Equatable { let revealRequestID: UUID? } +private struct VcsTreeStatusReader: View { + @ObservedObject var feature: VcsProvidersFeatureModel + let content: (VcsTreeStatusProjection) -> Content + + var body: some View { content(feature.treeStatus) } +} + private struct ProjectGitStatusSnapshot: Equatable { let repositoryRoot: URL? let projection: GitTreeStatusProjection + /// Statuses of plugin VCS roots; Git's own win where both report. + var vcs = VcsTreeStatusProjection() func kind(for url: URL, isDirectory: Bool) -> GitChangeKind? { return projection.kind(relativePath: url.standardizedFileURL.path, isDirectory: isDirectory) @@ -492,6 +513,10 @@ private struct ProjectGitStatusSnapshot: Equatable { return projection.change(relativePath: url.standardizedFileURL.path) } + func vcsStatus(for url: URL) -> VcsChangeStatus? { + vcs.isEmpty ? nil : vcs.status(for: url) + } + private static func relativePath(for url: URL, root: URL) -> String? { let normalizedRoot = root.standardizedFileURL.path let normalizedPath = url.standardizedFileURL.path @@ -512,6 +537,16 @@ private final class ProjectTreeActions: @unchecked Sendable { AgentFileAttachmentMenu.items(model: model, urls: urls) } + /// "Show Diff" for a file a plugin VCS lists as changed. + @MainActor + func vcsDiffMenuItem(_ url: URL) -> LitheContextMenuItem? { + guard let feature = model.vcsProvidersFeature, let (root, change) = feature.change(at: url), + root.comparesWithBase(change) else { return nil } + return .action(String(format: String(localized: "Show %@ Diff"), root.displayName), localizesTitle: false) { + feature.showDiff(change, in: root.id) + } + } + // Button and context-menu closures are not MainActor-isolated under the // Swift 6 test/release check. Keep these methods synchronous and hop. nonisolated func openFile(_ url: URL) { @@ -765,7 +800,7 @@ private struct FileNodeRow: View { .frame(width: LitheTheme.Metrics.treeIconSize, height: LitheTheme.Metrics.treeIconSize) Text(node.name) .font(LitheTheme.uiFont(size: LitheTheme.Metrics.treeFontSize, weight: depth == 0 ? .semibold : .regular)) - .foregroundStyle(gitStatusColor ?? LitheTheme.primaryText) + .foregroundStyle(statusColor ?? LitheTheme.primaryText) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -804,7 +839,7 @@ private struct FileNodeRow: View { .frame(width: LitheTheme.Metrics.treeIconSize) Text(node.name) .font(LitheTheme.uiFont(size: LitheTheme.Metrics.treeFontSize)) - .foregroundStyle(gitStatusColor ?? LitheTheme.primaryText) + .foregroundStyle(statusColor ?? LitheTheme.primaryText) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -814,6 +849,11 @@ private struct FileNodeRow: View { .font(LitheTheme.uiFont(size: 9, weight: .bold, design: .monospaced)) .foregroundStyle(gitStatusColor ?? LitheTheme.secondaryText) .accessibilityLabel(status.kind.title) + } else if let status = gitStatus.vcsStatus(for: node.url), status != .ignored { + Text(verbatim: status.letter) + .font(LitheTheme.uiFont(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(status.treeColor) + .accessibilityLabel(status.title) } } .padding(.leading, CGFloat(depth) * LitheTheme.Tree.indent) @@ -1043,6 +1083,8 @@ private struct FileNodeRow: View { actions.selectChange(change) } ] + } else if let item = actions.vcsDiffMenuItem(node.url) { + items.append(item) } // Windows parity: a Git submenu with the remaining single-file actions. @@ -1099,6 +1141,10 @@ private struct FileNodeRow: View { return items } + private var statusColor: Color? { + gitStatusColor ?? gitStatus.vcsStatus(for: node.url)?.treeColor + } + private var gitStatusColor: Color? { guard let kind = gitStatus.kind(for: node.url, isDirectory: node.isDirectory) else { return nil diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 06027af978..f61d8bca86 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -461,6 +461,18 @@ package protocol LanguageServerRuntimeCore: Sendable { params: ToolingJSONValue, timeout: TimeInterval ) -> Result + /// Sends one operation of the negotiated VCS-provider protocol. Its + /// completion arrives as a `requestCompleted` event with the reply under + /// `result.value`; a failure carries a `vcs.*` code. + func requestVcsProvider( + sessionID: String, + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + timeout: TimeInterval + ) -> Result /// Starts a process a plugin owns. Only such a session negotiates plugin /// functions, and Core lets it serve that plugin's functions alone. func startPluginLanguageServer( @@ -625,6 +637,21 @@ package extension LanguageServerRuntimeCore { )) } + func requestVcsProvider( + sessionID _: String, + vcs _: String, + operation _: String, + root _: String, + tool _: String?, + arguments _: ToolingJSONValue, + timeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "not_supported", + message: "VCS-provider requests are not supported by this runtime." + )) + } + /// Runtimes without plugin ownership start a host-owned session, which /// serves no plugin functions. func startPluginLanguageServer( diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index 2188331f2c..bc3220eb13 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -443,6 +443,8 @@ public struct DeclarativeLanguageServerLaunchPlan: Equatable, Sendable { public let executable: Executable? /// Set when the plan was selected by build system. public let buildProvider: BuildProvider? + /// Set when the plan was selected by VCS provider. + public var vcsProvider: VcsProvider? = nil public enum Executable: Equatable, Sendable { /// Package-relative file. @@ -468,6 +470,21 @@ public struct DeclarativeLanguageServerLaunchPlan: Equatable, Sendable { } } + /// What the host needs besides the process: the VCS the provider must + /// negotiate and the client tool it resolves for every request. + public struct VcsProvider: Equatable, Sendable { + public let vcs: String + /// Bare command names, resolved like a `systemCommand` launch. + public let toolCommands: [String] + public let toolValidationArguments: [String] + public let mayUseNetwork: Bool + + public init(vcs: String, toolCommands: [String], toolValidationArguments: [String], mayUseNetwork: Bool) { + self.vcs = vcs; self.toolCommands = toolCommands + self.toolValidationArguments = toolValidationArguments; self.mayUseNetwork = mayUseNetwork + } + } + public init( arguments: [String], validationArguments: [String] = [], @@ -559,6 +576,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { public let languageServers: [DeclarativeLanguageServer] public let executionProviders: [DeclarativeExecutionProvider] public let buildSystems: [DeclarativeBuildSystem] + /// VCS providers the plugin adds (`vcsProviders` capability). + public let vcsProviders: [DeclarativeVcsProvider] public let settings: [DeclarativePluginSetting] /// Buttons the plugin adds to settings pages (settings capability 2). public let settingActions: [DeclarativePluginSettingAction] @@ -604,6 +623,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { ) } buildSystems = decoded.buildSystems.map { $0.declaration() } + vcsProviders = decoded.vcsProviders.map { DeclarativeVcsProvider(id: $0.id, displayName: $0.displayName) } ui = decoded.ui settings = try decoded.settings.map { try $0.declaration() } settingActions = decoded.settingActions.map { $0.declaration() } @@ -654,6 +674,20 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } )) } + if !vcsProviders.isEmpty { + // One version-control window per provider, enabled with the + // plugin. Root detection, launch, and replies belong to Rust Core. + modules.append(PluginModuleDeclaration( + manifest: LanguageExtensionModuleManifests.vcsProviders( + pluginID: decoded.id, + displayName: decoded.displayName, + enabledByDefault: enabledByDefault + ), + contributions: vcsProviders.enumerated().flatMap { index, provider in + provider.contributions(pluginID: decoded.id, order: index) + } + )) + } if let ui = decoded.ui { modules.append(PluginModuleDeclaration( manifest: ModuleManifest( @@ -920,6 +954,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { struct JSONPresence: Decodable { init(from decoder: Decoder) throws {} } + /// The parts of a VCS provider the host shows. Root detection, + /// commands, and reply checks stay with Rust Core. + struct VcsProvider: Decodable { + let id: String + let displayName: String + } + /// The parts of a build system the tool window lists. Detection, /// commands, and decoders stay with Rust Core. struct BuildSystem: Decodable { @@ -1037,6 +1078,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let languageServers: [LanguageServer] let executionProviders: [ExecutionProvider] let buildSystems: [BuildSystem] + let vcsProviders: [VcsProvider] let ui: DeclarativeUIContributions? let settings: [Setting] let settingActions: [SettingAction] @@ -1055,13 +1097,14 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { languageServers = try values.decodeIfPresent([LanguageServer].self, forKey: .languageServers) ?? [] executionProviders = try values.decodeIfPresent([ExecutionProvider].self, forKey: .executionProviders) ?? [] buildSystems = try values.decodeIfPresent([BuildSystem].self, forKey: .buildSystems) ?? [] + vcsProviders = try values.decodeIfPresent([VcsProvider].self, forKey: .vcsProviders) ?? [] settings = try values.decodeIfPresent([Setting].self, forKey: .settings) ?? [] settingActions = try values.decodeIfPresent([SettingAction].self, forKey: .settingActions) ?? [] } private enum CodingKeys: String, CodingKey { case id, displayName, version, pluginAPIVersion, vendor, permissions, activationEvents - case languages, languageServers, executionProviders, buildSystems, ui, settings, settingActions + case languages, languageServers, executionProviders, buildSystems, vcsProviders, ui, settings, settingActions } } } @@ -1141,6 +1184,12 @@ public protocol DeclarativeLanguageServerLaunchPlanning: Sendable { buildSystemID: String, request: DeclarativeLanguageServerLaunchRequest ) throws -> DeclarativeLanguageServerLaunchPlan + /// The launch plan of a VCS provider, with `vcsProvider` set. + func vcsProviderLaunchPlan( + manifest: Data, + vcs: String, + request: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan } public extension DeclarativeLanguageServerLaunchPlanning { @@ -1154,6 +1203,17 @@ public extension DeclarativeLanguageServerLaunchPlanning { message: "This host cannot plan build provider launches." ) } + + func vcsProviderLaunchPlan( + manifest _: Data, + vcs _: String, + request _: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan { + throw DeclarativePluginRejection( + code: "plugin.launch.unavailable", + message: "This host cannot plan VCS provider launches." + ) + } } /// Reads a launch runtime's version from its probe output and compares it diff --git a/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift index eea82719d8..d0e09d167d 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift @@ -242,6 +242,9 @@ public struct PluginProgram: Equatable, Hashable, Sendable { case run case test case buildSystem + case packagedVcsProvider + case systemVcsProvider + case runtimeVcsProvider case unknown } @@ -306,6 +309,27 @@ public struct PluginProgram: Equatable, Hashable, Sendable { } } + /// VCS ID of a VCS provider program. + public var vcs: String? { string("vcs") } + + /// Client commands a VCS provider may run, in resolution order. They + /// belong to the approved program, so another client is a new approval. + public var vcsClientCommands: [String] { + guard case .object(let fields) = json, case .object(let tool)? = fields["vcsTool"], + case .array(let commands)? = tool["commands"] else { return [] } + return commands.compactMap { if case .string(let command) = $0 { command } else { nil } } + } + + /// Script a runtime launch runs, inside the plugin. + public var entry: String? { string("entry") } + public var runtimeArguments: [String] { strings("runtimeArguments") } + + /// The provider may reach a server, such as a Subversion repository. + public var mayUseNetwork: Bool { + guard case .object(let fields) = json, case .bool(let value)? = fields["mayUseNetwork"] else { return false } + return value + } + /// Task IDs of a `buildSystem` program. public var buildTaskIDs: [String] { guard case .object(let fields) = json, case .array(let tasks)? = fields["tasks"] else { return [] } diff --git a/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift new file mode 100644 index 0000000000..4df9e73d73 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift @@ -0,0 +1,438 @@ +import Foundation +import LitheModuleAPI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +// MARK: Declarations + +/// A VCS provider a declarative plugin adds (`vcsProviders` capability). +/// Rust Core owns its root detection, launch plan, requests, and reply +/// validation; the host only needs to know which VCS it is and its name. +public struct DeclarativeVcsProvider: Equatable, Sendable { + /// The VCS ID, such as `svn`, that roots and replies carry. + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } + + // MARK: Tool window + + /// Contribution visibility keys naming the provider a host-derived + /// window or command belongs to. + public static let pluginIDKey = "vcsProviderPluginID" + public static let vcsKey = "vcsProviderID" + + /// The host's tool window for one provider. Plugin-declared UI ids + /// cannot contain `:`, so this never collides with the plugin's own windows. + public static func toolWindowID(pluginID: String, vcs: String) -> String { + "\(pluginID).vcs-providers:\(vcs)" + } + + /// The tool window and its toggle command, derived from the declaration + /// like a build system's: every provider gets the same window, which + /// sits next to the Git commit window and shares its version control + /// icon (the `git` host icon), since a provider declares none. + public func contributions(pluginID: String, order: Int) -> [ModuleContribution] { + let windowID = Self.toolWindowID(pluginID: pluginID, vcs: id) + let visibility = [Self.pluginIDKey: pluginID, Self.vcsKey: id] + let icon = PluginUIIcon(host: "git") + return [ + ModuleContribution( + id: windowID, kind: .toolWindow, title: displayName, placement: .leftSidebar, + order: 210 + order, actionID: windowID + ".toggle", rendererID: windowID, + visibility: visibility, uiIcon: icon + ), + ModuleContribution( + id: windowID + ".command", kind: .command, title: displayName, placement: .commandPalette, + order: order, actionID: windowID + ".command", + visibility: visibility.merging(["toolWindowID": windowID]) { _, new in new }, uiIcon: icon + ) + ] + } +} + +/// An enabled plugin's VCS providers, as the host hands them to Rust Core. +package struct VcsProviderPluginSource: Equatable, Sendable { + package let pluginID: String + /// Normalized manifest from `plugins.declarative.validate`. + package let manifest: Data + /// Installed package directory, read-only, for `${pluginDir}`. + package let packageURL: URL + package let providers: [DeclarativeVcsProvider] + + package init(pluginID: String, manifest: Data, packageURL: URL, providers: [DeclarativeVcsProvider]) { + self.pluginID = pluginID; self.manifest = manifest; self.packageURL = packageURL + self.providers = providers + } +} + +/// The provider process of one VCS of one plugin in one workspace. Two +/// plugins, or two workspaces, never share a process. +package struct VcsProviderTarget: Hashable, Sendable { + package let pluginID: String + package let vcs: String + package let manifest: Data + package let packageURL: URL + package let workspaceURL: URL + + package init(pluginID: String, vcs: String, manifest: Data, packageURL: URL, workspaceURL: URL) { + self.pluginID = pluginID; self.vcs = vcs; self.manifest = manifest + self.packageURL = packageURL; self.workspaceURL = workspaceURL.standardizedFileURL + } +} + +// MARK: Negotiation + +/// Capabilities of protocol version 1. UI decisions read these only, never +/// the VCS ID. +package enum VcsCapability: String, CaseIterable, Sendable { + case status, baseContent, checkin, rollback, history, fileHistory, annotate, update + case push, staging, fileOperations, conflicts, cleanup +} + +/// What a provider answered in `initialize`, as Core's `vcsProvider` +/// runtime event reports it. +public struct VcsProviderNegotiation: Equatable, Sendable, Decodable { + public let vcs: String + /// Capability ID to version; Core drops what it cannot validate. + public let capabilities: [String: Int] + + public init(vcs: String, capabilities: [String: Int]) { + self.vcs = vcs + self.capabilities = capabilities + } + + package func supports(_ capability: VcsCapability) -> Bool { + capabilities[capability.rawValue] != nil + } +} + +// MARK: Detection + +/// One provider `vcs.detectRoots` knows, the built-in Git included. +package struct VcsProviderDescriptor: Equatable, Sendable, Decodable { + package struct Origin: Equatable, Sendable, Decodable { + /// `builtIn` or `plugin`. + package let kind: String + package let pluginID: String? + } + + package struct Tool: Equatable, Sendable, Decodable { + package let commands: [String] + package let validationArguments: [String]? + } + + /// An entry directly inside a root that marks it, such as `.svn`. + package struct Marker: Equatable, Sendable, Decodable { + package let name: String + /// `directory` or `file`. + package let type: String + } + + package let vcs: String + package let displayName: String + package let origin: Origin + package let markers: [Marker] + /// Root-relative files whose change means the status may be stale, + /// such as `.svn/wc.db`. Absent for the built-in Git. + package let metadataPaths: [String]? + package let tool: Tool? + package let mayUseNetwork: Bool? +} + +/// A working-copy root. `id` is relative to the workspace (`""` is the +/// workspace itself, `..` an enclosing working copy); `path` is absolute. +package struct VcsRoot: Equatable, Hashable, Sendable, Decodable { + package let id: String + package let vcs: String + package let path: String + /// The user chose this VCS for an ambiguous directory. + package let mapped: Bool + + package init(id: String, vcs: String, path: String, mapped: Bool = false) { + self.id = id; self.vcs = vcs; self.path = path; self.mapped = mapped + } +} + +/// A directory with markers of several VCSs. Core never guesses; it stays +/// unowned until the user maps it. +package struct VcsAmbiguousDirectory: Equatable, Sendable, Decodable { + package let id: String + package let vcs: [String] +} + +/// The user's choice for an ambiguous directory. +package struct VcsRootMapping: Equatable, Hashable, Sendable, Codable { + package let id: String + package let vcs: String + + package init(id: String, vcs: String) { + self.id = id; self.vcs = vcs + } +} + +package struct VcsDetection: Equatable, Sendable, Decodable { + package struct Rejected: Equatable, Sendable, Decodable { + package struct Error: Equatable, Sendable, Decodable { + package let code: String + package let message: String + } + + package let index: Int + package let error: Error + } + + package struct Conflict: Equatable, Sendable, Decodable { + package let pluginID: String + package let vcs: String + package let by: String + } + + package let providers: [VcsProviderDescriptor] + package let roots: [VcsRoot] + package let ambiguous: [VcsAmbiguousDirectory] + package let unusedMappings: [VcsRootMapping] + /// The directory budget ran out, so a root may be missing. + package let truncated: Bool + package let rejected: [Rejected] + package let conflicts: [Conflict] +} + +// MARK: Replies + +package struct VcsRevision: Equatable, Hashable, Sendable, Decodable { + package let id: String + package let display: String + /// `hash`, `number`, or `opaque`; presentation only. + package let kind: String +} + +package struct VcsHead: Equatable, Sendable, Decodable { + package let revision: VcsRevision? + package let branch: String? + package let location: String? +} + +package enum VcsChangeStatus: String, Equatable, Hashable, Sendable, Decodable { + case modified, added, deleted, missing, renamed, copied, replaced, typeChanged + case unversioned, ignored, conflicted, switched +} + +/// One changed path, relative to its root. +package struct VcsChange: Equatable, Hashable, Sendable, Decodable { + package let path: String + package let oldPath: String? + package let status: VcsChangeStatus + /// `external`, `locked`, `lockedByOther`, `propertiesModified`, + /// `submodule`, `treeConflict`; unknown flags never reach the host. + package let flags: [String] + + package init(path: String, oldPath: String? = nil, status: VcsChangeStatus, flags: [String] = []) { + self.path = path; self.oldPath = oldPath; self.status = status; self.flags = flags + } + + package var hasTreeConflict: Bool { flags.contains("treeConflict") } +} + +package struct VcsStatusSnapshot: Equatable, Sendable, Decodable { + package let head: VcsHead + package let changes: [VcsChange] + /// The list is incomplete and must not be shown as complete. + package let truncated: Bool +} + +package struct VcsBaseContent: Equatable, Sendable, Decodable { + /// Nil when the file has no base, or is binary. + package let content: String? + package let binary: Bool +} + +package struct VcsHistoryEntry: Equatable, Sendable, Decodable, Identifiable { + package let revision: VcsRevision + package let author: String? + package let date: String? + package let message: String + package let changes: [VcsChange] + package let changesTruncated: Bool + + package var id: String { revision.id } +} + +package struct VcsHistoryPage: Equatable, Sendable, Decodable { + package let entries: [VcsHistoryEntry] + package let nextCursor: String? +} + +package struct VcsCheckinResult: Equatable, Sendable, Decodable { + package let revision: VcsRevision? +} + +package struct VcsUpdateResult: Equatable, Sendable, Decodable { + package let revision: VcsRevision? + package let conflicts: [String] +} + +/// Replies of operations that return nothing. +package struct VcsEmptyResult: Equatable, Sendable, Decodable {} + +// MARK: Operations + +/// One operation of protocol version 1 with its arguments. The host names +/// operations, never methods; Core plans the request. +package enum VcsOperation: Equatable, Sendable { + case status(includeIgnored: Bool) + case baseContent(path: String) + case history(cursor: String?, limit: Int?) + case fileHistory(path: String, cursor: String?, limit: Int?) + case checkin(paths: [String], message: String) + case rollback(paths: [String]) + case update(revision: String?) + case add(paths: [String]) + case delete(paths: [String]) + case move(from: String, to: String) + case resolve(paths: [String]) + case cleanup + + package var name: String { + switch self { + case .status: "status" + case .baseContent: "baseContent" + case .history: "history" + case .fileHistory: "fileHistory" + case .checkin: "checkin" + case .rollback: "rollback" + case .update: "update" + case .add, .delete, .move: "fileOperation" + case .resolve: "resolve" + case .cleanup: "cleanup" + } + } + + package var capability: VcsCapability { + switch self { + case .status: .status + case .baseContent: .baseContent + case .history: .history + case .fileHistory: .fileHistory + case .checkin: .checkin + case .rollback: .rollback + case .update: .update + case .add, .delete, .move: .fileOperations + case .resolve: .conflicts + case .cleanup: .cleanup + } + } + + /// Whether the operation changes the working copy, so status must be + /// read again after it. + package var modifiesWorkingCopy: Bool { + switch self { + case .status, .baseContent, .history, .fileHistory: false + default: true + } + } + + /// The arguments as `lsp.vcsRequest` and `vcs.decodeReply` take them. + package var arguments: ToolingJSONValue { + func paths(_ values: [String]) -> ToolingJSONValue { .array(values.map { .string($0) }) } + func optional(_ value: String?) -> ToolingJSONValue { value.map { .string($0) } ?? .null } + var members: [String: ToolingJSONValue] = [:] + switch self { + case .status(let includeIgnored): + if includeIgnored { members["includeIgnored"] = .bool(true) } + case .baseContent(let path): + members["path"] = .string(path) + case .history(let cursor, let limit): + if let cursor { members["cursor"] = .string(cursor) } + if let limit { members["limit"] = .integer(limit) } + case .fileHistory(let path, let cursor, let limit): + members["path"] = .string(path) + if let cursor { members["cursor"] = .string(cursor) } + if let limit { members["limit"] = .integer(limit) } + case .checkin(let values, let message): + members["paths"] = paths(values) + members["message"] = .string(message) + case .rollback(let values), .resolve(let values): + members["paths"] = paths(values) + case .update(let revision): + if let revision { members["revision"] = optional(revision) } + case .add(let values): + members["kind"] = .string("add") + members["paths"] = paths(values) + case .delete(let values): + members["kind"] = .string("delete") + members["paths"] = paths(values) + case .move(let from, let to): + members["kind"] = .string("move") + members["from"] = .string(from) + members["to"] = .string(to) + case .cleanup: + break + } + return .object(members) + } +} + +/// A failed VCS request. `code` is a contract code such as `vcs.outOfDate`, +/// or `plugin.vcs.invalidOutput` when Core rejected the reply. +package struct VcsProviderFailure: LocalizedError, Equatable, Sendable { + package let code: String + package let message: String + + package init(code: String, message: String) { + self.code = code; self.message = message + } + + package var errorDescription: String? { message } +} + +// MARK: Ports + +/// Rust Core's `vcs.*` commands. Implementations throw +/// `DeclarativePluginRejection` when Core cannot be reached and +/// `VcsProviderFailure` for a rejected reply. +package protocol VcsRootPlanning: Sendable { + func detectRoots( + workspaceURL: URL, + manifests: [Data], + mappings: [VcsRootMapping] + ) throws -> VcsDetection + /// The root ID owning each workspace-relative path, in request order. + func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] + func decodeReply( + _ operation: VcsOperation, + capabilities: [String: Int], + reply: ToolingJSONValue, + as type: Value.Type + ) throws -> Value +} + +/// A provider's reply with the negotiation of the session that sent it. +package struct VcsProviderReply: Sendable { + package let value: ToolingJSONValue + package let negotiation: VcsProviderNegotiation + + package init(value: ToolingJSONValue, negotiation: VcsProviderNegotiation) { + self.value = value; self.negotiation = negotiation + } +} + +/// Provider processes. A request starts the target's process when none +/// runs; the reply is the provider's raw value, which the caller decodes +/// through `VcsRootPlanning.decodeReply`. +@MainActor +package protocol VcsProviderRequesting: AnyObject { + /// The target's negotiation, starting its process when needed. + func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation + func send( + _ operation: VcsOperation, + root: String, + to target: VcsProviderTarget, + timeout: Duration + ) async throws -> VcsProviderReply + /// Stops every process whose target `keep` rejects. + func retainProviders(_ keep: (VcsProviderTarget) -> Bool) +} diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift index 157b0d4283..1f1ad6caa4 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift @@ -1,8 +1,10 @@ import Foundation package struct FileVisibilityRules: Hashable, Sendable { + /// VCS metadata (`.git`, `.svn`) is hidden like IDEA's default ignore + /// list; providers watch their own metadata directories. package static let builtInHiddenDirectories = [ - ".git", ".worktree", ".worktrees", ".build", ".swiftpm", "node_modules", "target", "build", + ".git", ".svn", ".worktree", ".worktrees", ".build", ".swiftpm", "node_modules", "target", "build", "DerivedData", ".gradle", ".next", "dist", "coverage", "design-qa-artifacts", ".artifacts" ] /// Names and globs omitted from the project tree, search index, watchers, diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 481c520337..71afb65c7d 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -91,6 +91,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// `functionsChanged` event listed them. package private(set) var functionIDs: [String] = [] package var onFunctionsChange: (([String]) -> Void)? + /// The VCS provider the process negotiated in `initialize`, as Core's + /// `vcsProvider` event reported it; nil for every other session. + package private(set) var vcsNegotiation: VcsProviderNegotiation? package var onServerInfoChange: ((LanguageServerInfo?) -> Void)? package var javaTestRunnerURL: URL? { hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources?.javaTestRunnerURL @@ -582,6 +585,51 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Sends one operation of the negotiated VCS-provider protocol and + /// returns the provider's raw reply, which the caller passes to + /// `vcs.decodeReply`. A refusal or failure throws + /// `LanguageServerRequestFailure` with the `vcs.*` code. + package func vcsRequest( + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + timeout: TimeInterval + ) async throws -> ToolingJSONValue { + guard let sessionID, state == .ready else { + throw LanguageServerRuntimeSessionError.notReady + } + let started: LanguageServerRuntimeOperation + switch await send(timeout: timeout, { core in + core.requestVcsProvider( + sessionID: sessionID, vcs: vcs, operation: operation, root: root, tool: tool, + arguments: arguments, timeout: timeout + ) + }) { + case .success(let operation): started = operation + case .failure(let error): + sendsInFlight -= 1 + try Task.checkCancellation() + throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + registerPendingOperation(started, name: "vcs.\(operation)") { result in + continuation.resume(with: result.flatMap { event in + guard case .object(let object)? = event.result, let value = object["value"] else { + return .failure(LanguageServerRuntimeSessionError.missingResult) + } + return .success(value) + }) + } + sendsInFlight -= 1 + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancelOffMain(started.operationID) } + } + } + /// Invokes a plugin function of the plugin that owns this session. Core /// checks ownership and registration; a refusal or a failed call throws /// `PluginFunctionFailure`, and cancelling the task cancels the call. @@ -994,6 +1042,12 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { if case .integer(let value)? = object["percentage"] { percentage = value } listener(PluginFunctionProgress(message: message, percentage: percentage)) return false + case "vcsProvider": + switch Self.decodeEventResult(event, as: VcsProviderNegotiation.self) { + case .success(let negotiation): vcsNegotiation = negotiation + case .failure(let error): onLog?(.error, "Invalid VCS provider event", error.localizedDescription, nil) + } + return false case "featuresChanged": updateFeatures(capabilityNames: event.capabilities ?? []) return false @@ -1068,6 +1122,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { functionIDs = [] onFunctionsChange?([]) } + vcsNegotiation = nil let released = onReleased onReleased = nil released?() diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 3e41a999ab..7531ddd32d 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -347,4 +347,22 @@ public enum LanguageExtensionModuleManifests { dependencies: [.module(.workspace)] ) } + + /// One module for all VCS providers of a plugin. Like build systems, it + /// owns no process: the host starts a provider only for a root of its VCS + /// while the module is enabled. + public static func vcsProviders( + pluginID: String, + displayName: String, + enabledByDefault: Bool = false + ) -> ModuleManifest { + ModuleManifest( + id: .vcsProvidersExtension(pluginID), + displayName: "\(displayName) Version Control", + scope: .workspace, + defaultState: enabledByDefault ? .enabled : .disabled, + activationPolicy: .onDemand, + dependencies: [.module(.workspace)] + ) + } } diff --git a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index 63ec8f2b24..17e0f23364 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -304,6 +304,11 @@ public extension ModuleID { static func buildSystemsExtension(_ pluginID: String) -> ModuleID { ModuleID("\(pluginID).build-systems") } + + /// The VCS providers one declarative plugin declares, enabled together. + static func vcsProvidersExtension(_ pluginID: String) -> ModuleID { + ModuleID("\(pluginID).vcs-providers") + } } public extension ModuleCapabilityID { diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 2366cb11ad..2f6d3033f9 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -497,7 +497,8 @@ public struct PluginManifest: Equatable, Codable, Sendable { "languages": [1], "serverProcess": [1], "settings": [1, 2], - "ui": [1, 2] + "ui": [1, 2], + "vcsProviders": [1] ] public let schemaVersion: Int diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 99318b8c44..c6b5e4cb66 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -67,6 +67,9 @@ package final class WorkspaceFeatureModel: ObservableObject { private var notify: (@MainActor (String) -> Void)? private var recordHistory: (@MainActor (URL, LocalHistoryReason) async -> Void)? private var relocateHistory: (@MainActor (URL, URL) async -> Void)? + /// Moves an item through the version control that tracks it, so the + /// move is recorded; false leaves the move to the file system. + private var moveTrackedItem: (@MainActor (URL, URL) async -> Bool)? private var relocateOpenDocuments: (@MainActor (URL, URL) -> Void)? private var closeDocuments: (@MainActor (URL) -> Void)? private var processExternalChanges: (@MainActor ([URL]) -> Bool)? @@ -108,6 +111,7 @@ package final class WorkspaceFeatureModel: ObservableObject { notify: @escaping @MainActor (String) -> Void, recordHistory: @escaping @MainActor (URL, LocalHistoryReason) async -> Void, relocateHistory: @escaping @MainActor (URL, URL) async -> Void, + moveTrackedItem: @escaping @MainActor (URL, URL) async -> Bool = { _, _ in false }, relocateOpenDocuments: @escaping @MainActor (URL, URL) -> Void, closeDocuments: @escaping @MainActor (URL) -> Void, processExternalChanges: @escaping @MainActor ([URL]) -> Bool, @@ -129,6 +133,7 @@ package final class WorkspaceFeatureModel: ObservableObject { self.notify = notify self.recordHistory = recordHistory self.relocateHistory = relocateHistory + self.moveTrackedItem = moveTrackedItem self.relocateOpenDocuments = relocateOpenDocuments self.closeDocuments = closeDocuments self.processExternalChanges = processExternalChanges @@ -557,7 +562,12 @@ package final class WorkspaceFeatureModel: ObservableObject { } let fileOperations = self.fileOperations - let errorMessage = await Task.detached(priority: .userInitiated) { () -> String? in + var movedByVersionControl = false + if request.kind == .rename, let moveTrackedItem { + let exists = await Task.detached(priority: .userInitiated) { fileOperations.fileExists(at: destination) }.value + if !exists { movedByVersionControl = await moveTrackedItem(request.targetURL, destination) } + } + let errorMessage = movedByVersionControl ? nil : await Task.detached(priority: .userInitiated) { () -> String? in guard !fileOperations.fileExists(at: destination) else { return "An item named '\(name)' already exists" } @@ -662,7 +672,14 @@ package final class WorkspaceFeatureModel: ObservableObject { } let historyFiles = projectFiles.filter { urlContains(source, child: $0) } let previousMarks = directoryMarks - let error = await Task.detached(priority: .userInitiated) { () -> String? in + var movedByVersionControl = false + if let moveTrackedItem { + let preflight = await Task.detached(priority: .userInitiated) { + Self.projectMoveError([source], to: target, workspace: workspace, files: fileOperations) + }.value + if preflight == nil { movedByVersionControl = await moveTrackedItem(source, destination) } + } + let error = movedByVersionControl ? nil : await Task.detached(priority: .userInitiated) { () -> String? in do { // Recheck just before writing; the native move also refuses overwrite. if let error = Self.projectMoveError([source], to: target, workspace: workspace, files: fileOperations) { diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index 17a302dfc0..3d75f5bc71 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -309,6 +309,48 @@ struct AppLocalizationTests { #expect(translations["Developer Tools"] == "开发者工具") } + @Test + func simplifiedChineseResourcesCoverPluginVcsProviders() throws { + let translations = try simplifiedChineseTranslations() + let requiredKeys = [ + // Install review. + "%@ provider (inside the plugin)", "%@ provider (installed on this Mac)", + "%1$@ provider (runs with %2$@)", "Client: %@", "May connect to the repository server", + // Tool window. + "Conflicts", "Changes", "Unversioned Files", "History", "Update Project", "Roll Back…", + "Show Diff", "Mark as Resolved", "Clean Up Working Copy", "More Actions", "Working copy", + "%@ holds the metadata of several version control systems. Choose the one that manages it.", + "Detecting working copies…", "No %@ working copy was detected in this workspace.", "Detect Again", + "Moved from %@", "Tree conflict", "Locked in this working copy", "Locked by another user", + "%lld files selected", "Committed revision %@.", "Updated to revision %@.", "%lld files have conflicts.", + "Roll back the selected changes?", "Local edits to these files are lost.", "History of %@", + "Restart Lithe to use %@.", + // Diff and history. + "Show %@ Diff", "Base revision", "Binary files cannot be compared.", + "The file is too large to compare.", "No revisions", "More paths changed than are shown.", + // Provider failures. + "Wait for the running version control action to finish.", + "The working copy is out of date. Update it, then commit again.", + "The working copy is locked by an interrupted action. Run Cleanup, then try again.", + "Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again.", + "%1$@ does not provide %2$@ because %3$@ already does." + ] + + for key in requiredKeys { + let value = translations[key] + #expect(value != nil, "Missing VCS provider translation: \(key)") + // A translation must keep every placeholder, or String(format:) misreads its arguments. + #expect(value.map(Self.placeholders) == Self.placeholders(key), "Placeholders differ: \(key)") + } + #expect(translations["Base revision"] == "基线版本") + #expect(translations["Mark as Resolved"] == "标记为已解决") + } + + private static func placeholders(_ format: String) -> [String] { + let regex = /%(?:\d+\$)?(?:@|lld)/ + return format.matches(of: regex).map { String($0.output) }.sorted() + } + private func simplifiedChineseTranslations() throws -> [String: String] { let repositoryRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index fe55a47fc0..7a89559bb1 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -66,6 +66,26 @@ struct PluginManagerTests { ) } + @Test + func vcsProviderPluginGetsOneDisabledModuleForItsProviders() throws { + // VCS providers come from community plugins; the contract's + // manifest fixture stands in for one. Core accepts it because this + // host declares `vcsProviders`, and one module carries its enablement. + #expect(PluginManifest.hostCapabilities["vcsProviders"] == [1]) + let manifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json")) + let sample = try DeclarativePluginTestSupport.operations.validate(manifest: manifest) + #expect(sample.vcsProviders == [DeclarativeVcsProvider(id: "svn", displayName: "Subversion")]) + #expect(sample.pluginManifest.modules.map(\.manifest.id) == [.vcsProvidersExtension(sample.id.rawValue)]) + #expect(sample.pluginManifest.modules.allSatisfy { $0.manifest.defaultState == .disabled }) + _ = try ValidatedPluginCatalog( + manifests: BuiltInPluginCatalog.manifests + + BundledLanguagePluginCatalog.manifests + + [sample.pluginManifest], + hostVersion: BuiltInPluginCatalog.hostVersion + ) + } + @Test func internalLifecycleModulesAreNotShownAsInstalledPlugins() { let root = temporaryRoot() diff --git a/macos/Tests/LitheTests/VcsProviderHostTests.swift b/macos/Tests/LitheTests/VcsProviderHostTests.swift new file mode 100644 index 0000000000..bcbd43615a --- /dev/null +++ b/macos/Tests/LitheTests/VcsProviderHostTests.swift @@ -0,0 +1,975 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import Testing +@testable import Lithe + +/// The macOS host side of plugin VCS providers: the feature model against +/// a fake Core and fake provider processes, the real Core adapter, the +/// install review, and the tool window's pure presentation rules. +@Suite("VCS provider host") +@MainActor +struct VcsProviderHostTests { + // MARK: Feature model + + @Test + func detectsPluginRootsReadsStatusAndColorsOnlyTheFilesTheRootOwns() async throws { + // A Git repository nested in the working copy takes part in + // ownership but is not listed; its directory is the Git root's. + let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("vendor/lib", "git")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("a.txt", "modified"), ("build", "unversioned"), ("vendor/lib", "unversioned"), ("debug.log", "ignored") + ]) + + #expect(await fixture.open()) + #expect(fixture.feature.roots.map(\.id) == [""]) + #expect(fixture.feature.roots.first?.title == "workspace") + #expect(fixture.feature.roots.first?.snapshot?.head.revision?.display == "r7") + #expect(fixture.feature.vcsNames == ["svn": "Subversion", "git": "Git"]) + let feature = fixture.feature + let modifiedURL = fixture.url("a.txt") + let colored = await awaitChange(on: feature) { feature.treeStatus.status(for: modifiedURL) == .modified } + #expect(colored) + let tree = feature.treeStatus + // Rows below an unversioned directory share its status. + #expect(tree.status(for: fixture.url("build/classes/Main.class")) == .unversioned) + #expect(tree.status(for: fixture.url("vendor/lib")) == nil) + #expect(tree.status(for: fixture.url("vendor/lib/x.c")) == nil) + #expect(tree.status(for: fixture.url("debug.log")) == nil) + #expect(fixture.providers.requests == [.init(operation: .status(includeIgnored: false), root: fixture.workspaceURL.path)]) + #expect(fixture.providers.retainCount > 0) + } + + @Test + func refreshesOnlyForWorkingFilesAndDeclaredMetadataOutsideHostActions() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("module", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + let workspace = fixture.workspaceURL.path + let module = fixture.url("module").path + #expect(await fixture.open()) + #expect(fixture.providers.statusCount(root: workspace) == 1) + #expect(fixture.providers.statusCount(root: module) == 1) + fixture.clock.advance(by: .seconds(3)) + + // Metadata the provider did not declare never refreshes; a working + // file does. Both arrive before the delay, so one refresh covers them. + fixture.metadataWatchers.deliver([fixture.url(".svn/tmp/x")]) + fixture.changed("module/File.java") + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 2 }) + #expect(fixture.providers.statusCount(root: workspace) == 1) + + // The host's own action writes metadata; those events are ignored + // for a short while, working files still count. + #expect(await fixture.feature.cleanup("module")) + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 3 }) + fixture.metadataWatchers.deliver([fixture.url(".svn/wc.db")]) + fixture.changed("module/Other.java") + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 4 }) + #expect(fixture.providers.statusCount(root: workspace) == 1) + + // Once the quiet period ends, the declared metadata path refreshes + // its root, as an update from the command line would. + fixture.clock.advance(by: .seconds(3)) + fixture.metadataWatchers.deliver([fixture.url(".svn/wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: workspace) == 2 }) + #expect(fixture.providers.statusCount(root: module) == 4) + } + + /// The workspace watcher hides VCS metadata as the project tree does, + /// and never sees `../.svn` of a working copy that encloses the + /// workspace, so every root's metadata directory is watched on its own. + @Test + func watchesTheMetadataDirectoryOfEveryRoot() async throws { + let fixture = try VcsFeatureFixture( + workspaceURL: URL(fileURLWithPath: "/virtual/wc/app", isDirectory: true), + roots: [("..", "svn"), ("lib", "svn")] + ) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + let enclosing = fixture.workspaceURL.appendingPathComponent("..").path + let lib = fixture.url("lib").path + let metadata = URL(fileURLWithPath: "/virtual/wc/.svn", isDirectory: true) + #expect(await fixture.open()) + #expect(fixture.metadataWatchers.running == [metadata.path, fixture.url("lib/.svn").path]) + #expect(fixture.providers.statusCount(root: enclosing) == 1) + fixture.clock.advance(by: .seconds(3)) + + fixture.metadataWatchers.deliver([metadata.appendingPathComponent("wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: enclosing) == 2 }) + #expect(fixture.providers.statusCount(root: lib) == 1) + // That status read starts the host's quiet period for every root. + fixture.clock.advance(by: .seconds(3)) + fixture.metadataWatchers.deliver([fixture.url("lib/.svn/wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: lib) == 2 }) + #expect(fixture.providers.statusCount(root: enclosing) == 2) + + // A watch that lost its events detects the roots again. + fixture.clock.advance(by: .seconds(3)) + fixture.metadataWatchers.deliver(DirectoryChangeBatch(requiresFullRescan: true)) + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(fixture.metadataWatchers.running == [metadata.path, fixture.url("lib/.svn").path]) + + fixture.close() + #expect(fixture.metadataWatchers.running.isEmpty) + } + + /// The workspace watcher hides `.svn`, so a working copy checked out in + /// the workspace shows only as created entries beside its marker. + @Test + func detectsAWorkingCopyThatAppearsInTheWorkspace() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + fixture.files.add(fixture.url(".svn"), fixture.url("plugins/.svn")) + #expect(await fixture.open()) + fixture.clock.advance(by: .seconds(3)) + + // A created entry and the directory holding it are looked up unless + // they are a known root; a changed file is not looked up at all. + let lookedUp = fixture.files.gate(afterLookups: 3) + fixture.changed("plugins/Plugin.java") + fixture.created("README.md", "src/Main.java") + #expect(await lookedUp.waitUntilOpen(timeout: .seconds(5))) + #expect(Set(fixture.files.lookups) + == Set(["README.md/.svn", "src/Main.java/.svn", "src/.svn"].map { fixture.url($0).path })) + + fixture.created("plugins/Plugin.java") + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(!fixture.files.lookups.contains(fixture.url(".svn").path)) + } + + @Test + func runsOneWorkingCopyActionAtATime() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + fixture.providers.replies["checkin"] = try VcsTestData.json(["revision": ["id": "8", "display": "r8", "kind": "number"]]) + #expect(await fixture.open()) + let checkinHeld = TestGate() + fixture.providers.holds["checkin"] = checkinHeld + defer { checkinHeld.open() } + + let feature = fixture.feature + let commit = Task { await feature.checkin(["a.txt"], message: "Fix", in: "") } + #expect(await awaitChange(on: feature) { feature.runningOperation?.rootID == "" }) + #expect(await feature.rollback(["a.txt"], in: "") == false) + #expect(feature.failure?.message == String(localized: "Wait for the running version control action to finish.")) + + checkinHeld.open() + #expect(await commit.value?.revision?.display == "r8") + #expect(feature.runningOperation == nil) + #expect(fixture.providers.requests.map(\.operation.name).filter { $0 != "status" } == ["checkin"]) + #expect(fixture.providers.requests.contains( + .init(operation: .checkin(paths: ["a.txt"], message: "Fix"), root: fixture.workspaceURL.path) + )) + } + + @Test + func movesTrackedItemsThroughTheProviderAndLeavesTheRestToTheDisk() async throws { + let fixture = try VcsFeatureFixture( + roots: [("", "svn"), ("module", "svn"), ("lib", "git")], ambiguous: ["mixed"] + ) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("a.txt", "modified"), ("new.txt", "unversioned"), ("out", "unversioned") + ]) + #expect(await fixture.open()) + let feature = fixture.feature + #expect(await awaitChange(on: feature) { feature.treeStatus.status(for: fixture.url("new.txt")) != nil }) + + #expect(await feature.move(from: fixture.url("a.txt"), to: fixture.url("docs/a.txt"))) + #expect(fixture.providers.requests.contains( + .init(operation: .move(from: "a.txt", to: "docs/a.txt"), root: fixture.workspaceURL.path) + )) + // The VCS does not track an unversioned item, cannot move into an + // unversioned directory, and cannot move between working copies. + #expect(await feature.move(from: fixture.url("new.txt"), to: fixture.url("docs/new.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("out/b.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("module/b.txt")) == false) + // A nested Git repository and a directory with no VCS chosen are not + // the working copy's, on either end of the move. + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("lib/b.txt")) == false) + #expect(await feature.move(from: fixture.url("lib/c.txt"), to: fixture.url("c.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("mixed/b.txt")) == false) + #expect(await feature.move(from: fixture.url("mixed/c.txt"), to: fixture.url("c.txt")) == false) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) + + // A refusal is reported; the caller then moves the item on disk. + fixture.providers.failures["fileOperation"] = VcsProviderFailure(code: "vcs.provider.failed", message: "E155010") + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("c.txt")) == false) + #expect(feature.failure?.message == "E155010") + } + + /// An action that starts while a move asks Core who owns its paths + /// leaves the move to the disk, as one running before would, instead of + /// reporting the move as refused. + @Test + func moveYieldsToAnActionThatStartsWhileOwnershipIsChecked() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + fixture.providers.replies["checkin"] = try VcsTestData.json(["revision": ["id": "8", "display": "r8", "kind": "number"]]) + #expect(await fixture.open()) + let ownersEntered = TestGate() + let ownersReleased = TestGate() + let checkinHeld = TestGate() + fixture.planner.heldOwners = (paths: ["b.txt"], entered: ownersEntered, released: ownersReleased) + fixture.providers.holds["checkin"] = checkinHeld + defer { + ownersReleased.open() + checkinHeld.open() + } + + let feature = fixture.feature + let source = fixture.url("b.txt") + let destination = fixture.url("docs/b.txt") + let move = Task { await feature.move(from: source, to: destination) } + #expect(await ownersEntered.waitUntilOpen(timeout: .seconds(5))) + let commit = Task { await feature.checkin(["a.txt"], message: "Fix", in: "") } + #expect(await awaitChange(on: feature) { feature.runningOperation?.rootID == "" }) + ownersReleased.open() + + #expect(await move.value == false) + #expect(feature.failure == nil) + checkinHeld.open() + #expect(await commit.value?.revision?.display == "r8") + #expect(!fixture.providers.requests.contains { $0.operation.name == "fileOperation" }) + #expect(!fixture.planner.ownersTimedOut) + } + + @Test + func remembersTheChosenVcsPerWorkspaceAndForgetsChoicesNoLongerNeeded() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")], ambiguous: ["mixed"]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + #expect(await fixture.open()) + #expect(fixture.feature.ambiguous.map(\.id) == ["mixed"]) + let store = MacVcsRootMappingStore(store: fixture.keyValues) + let choice = VcsRootMapping(id: "mixed", vcs: "svn") + + fixture.feature.map("mixed", to: "svn") + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(fixture.planner.lastMappings == [choice]) + #expect(store.mappings(workspaceURL: fixture.workspaceURL) == [choice]) + #expect(store.mappings(workspaceURL: fixture.url("other")).isEmpty) + + // Core reports the choice unused once the directory is no longer + // ambiguous, and the host drops it. + fixture.planner.detection = try VcsTestData.detection( + roots: [("", "svn")], workspaceURL: fixture.workspaceURL, unusedMappings: [choice] + ) + fixture.feature.detect() + #expect(await fixture.settled { fixture.planner.detectCount == 3 }) + #expect(store.mappings(workspaceURL: fixture.workspaceURL).isEmpty) + #expect(fixture.keyValues.isEmpty) + } + + @Test + func mappingStoreKeepsChoicesSortedPerWorkspace() { + let keyValues = VcsTestKeyValueStore() + let store = MacVcsRootMappingStore(store: keyValues) + let workspace = URL(fileURLWithPath: "/virtual/a") + let mappings = [VcsRootMapping(id: "z", vcs: "svn"), VcsRootMapping(id: "a", vcs: "git")] + + store.setMappings(mappings, workspaceURL: workspace) + #expect(store.mappings(workspaceURL: workspace) == mappings.reversed()) + #expect(store.mappings(workspaceURL: URL(fileURLWithPath: "/virtual/b")).isEmpty) + store.setMappings([], workspaceURL: workspace) + #expect(keyValues.isEmpty) + } + + @Test + func providerFailuresLeadWithTheHintTheContractNames() { + let outOfDate = VcsProviderFailure(code: "vcs.outOfDate", message: "E160024: out of date") + #expect(VcsProvidersFeatureModel.message(for: outOfDate) + == String(localized: "The working copy is out of date. Update it, then commit again.") + "\nE160024: out of date") + #expect(VcsProvidersFeatureModel.message(for: VcsProviderFailure(code: "vcs.workingCopy.locked", message: "")) + == String(localized: "The working copy is locked by an interrupted action. Run Cleanup, then try again.")) + #expect(VcsProvidersFeatureModel.message(for: VcsProviderFailure(code: "vcs.provider.failed", message: "boom")) + == "boom") + } + + @Test + func rootPathsMapToTheWorkspaceEvenFromAnEnclosingWorkingCopy() { + let workspace = URL(fileURLWithPath: "/virtual/wc/app/web") + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "", workspaceURL: workspace) == "a.txt") + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "lib", workspaceURL: workspace) == "lib/a.txt") + // The workspace opened two levels inside the working copy. + #expect(VcsProvidersFeatureModel.workspacePath("app/web/src/a.txt", rootID: "../..", workspaceURL: workspace) + == "src/a.txt") + #expect(VcsProvidersFeatureModel.workspacePath("app/web", rootID: "../..", workspaceURL: workspace) == "") + #expect(VcsProvidersFeatureModel.workspacePath("app/webapp/a.txt", rootID: "../..", workspaceURL: workspace) == nil) + #expect(VcsProvidersFeatureModel.workspacePath("README", rootID: "../..", workspaceURL: workspace) == nil) + // Core's rule: only IDs made of `..` segments enclose the workspace; + // a folder inside it named `..repo` is an ordinary nested root. + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "..repo", workspaceURL: workspace) == "..repo/a.txt") + #expect(VcsRootState.isEnclosingRootID("..") && VcsRootState.isEnclosingRootID("../..")) + #expect(!VcsRootState.isEnclosingRootID("..repo") && !VcsRootState.isEnclosingRootID("../..repo") + && !VcsRootState.isEnclosingRootID("")) + } + + // MARK: Tool window rules + + @Test + func changeListPutsConflictsFirstAndIncludesOnlyVersionedChangesByDefault() throws { + let changes = [ + VcsChange(path: "z.txt", status: .modified), + VcsChange(path: "new.txt", status: .unversioned), + VcsChange(path: "b.txt", status: .modified, flags: ["treeConflict"]), + VcsChange(path: "a.txt", status: .conflicted), + VcsChange(path: "c.txt", status: .added), + VcsChange(path: "debug.log", status: .ignored) + ] + let groups = VcsProviderToolWindowView.groups(changes) + + #expect(groups.map(\.group) == [.conflicts, .changes, .unversioned]) + #expect(groups.map { $0.changes.map(\.path) } == [["a.txt", "b.txt"], ["c.txt", "z.txt"], ["new.txt"]]) + #expect(changes.filter(VcsProviderToolWindowView.isIncludedByDefault).map(\.path) == ["z.txt", "c.txt"]) + } + + /// A provider declares no icon; its window and command show the host's + /// version control icon, not the generic tool window one. + @Test + func providerWindowsShowTheVersionControlIcon() { + let contributions = DeclarativeVcsProvider(id: "svn", displayName: "Subversion") + .contributions(pluginID: VcsTestData.pluginID, order: 0) + #expect(contributions.map(\.uiIcon) == [PluginUIIcon(host: "git"), PluginUIIcon(host: "git")]) + let asset = WorkbenchContributionIcon.Asset(contributions[0].uiIcon, resources: nil) + #expect(asset.hostPath == "expui/toolwindows/vcs@20x20.svg") + } + + @Test + func commitCarriesBothHalvesOfAMoveOnce() { + let paths = VcsProviderToolWindowView.commitPaths([ + VcsChange(path: "src/New.java", oldPath: "src/Old.java", status: .renamed), + VcsChange(path: "src/Old.java", status: .deleted), + VcsChange(path: "README", status: .modified) + ]) + #expect(paths == ["src/New.java", "src/Old.java", "README"]) + } + + @Test + func headAndHistoryDatesReadAsTheProviderSentThem() throws { + let head = try JSONDecoder().decode(VcsHead.self, from: Data( + #"{"revision":{"id":"7","display":"r7","kind":"number"},"branch":"trunk","location":null}"#.utf8 + )) + #expect(VcsProviderToolWindowView.headSummary(head) == "r7 · trunk") + + let date = try #require(ISO8601DateFormatter().date(from: "2026-10-22T08:30:00Z")) + let expected = date.formatted(date: .abbreviated, time: .shortened) + #expect(VcsHistoryListView.displayDate("2026-10-22T08:30:00Z") == expected) + #expect(VcsHistoryListView.displayDate("2026-10-22T08:30:00.250Z") == expected) + #expect(VcsHistoryListView.displayDate("yesterday") == "yesterday") + } + + // MARK: Rust Core and install review + + @Test + func rustCoreDetectsAPluginRootBesideNestedGitAndValidatesReplies() throws { + let workspace = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-host-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + try FileManager.default.createDirectory(at: workspace.appendingPathComponent(".svn"), withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: workspace.appendingPathComponent("vendor/lib/.git"), withIntermediateDirectories: true + ) + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: VcsTestData.svnProviderManifest()) + let planner = RustVcsOperations(core: RustCoreBridge()) + + let detection = try planner.detectRoots( + workspaceURL: workspace, manifests: [descriptor.normalizedManifest], mappings: [] + ) + #expect(Set(detection.roots.map { "\($0.vcs):\($0.id)" }) == ["svn:", "git:vendor/lib"]) + let svn = try #require(detection.providers.first { $0.vcs == "svn" }) + #expect(svn.origin.pluginID == descriptor.id.rawValue) + #expect(svn.metadataPaths == [".svn/wc.db"]) + #expect(try planner.owners( + roots: detection.roots.map(\.id), ambiguous: [], paths: ["a.txt", "vendor/lib/b.c", "vendor/c.txt"] + ) == ["", "vendor/lib", ""]) + + let snapshot = try planner.decodeReply( + .status(includeIgnored: false), capabilities: ["status": 1], + reply: VcsTestData.statusReply([("z.txt", "modified"), ("a.txt", "added")]), as: VcsStatusSnapshot.self + ) + #expect(snapshot.changes.map(\.path) == ["a.txt", "z.txt"]) + do { + _ = try planner.decodeReply( + .status(includeIgnored: false), capabilities: ["status": 1], + reply: VcsTestData.statusReply([("a.txt", "obstructed")]), as: VcsStatusSnapshot.self + ) + Issue.record("Core accepted an unknown status") + } catch let failure as VcsProviderFailure { + #expect(failure.code == "plugin.vcs.invalidOutput") + } + } + + /// A VCS client that cannot be found is the contract's + /// `vcs.tool.unavailable`, so the tool window leads with its install hint. + @Test + func aVcsClientThatCannotBeFoundIsReportedAsUnavailable() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-tool-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let packageURL = root.appendingPathComponent("package", isDirectory: true) + let workspaceURL = root.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) + let object = try #require( + try JSONSerialization.jsonObject(with: VcsTestData.svnProviderManifest()) as? [String: Any] + ) + _ = try DeclarativePluginTestSupport.writePackage(at: packageURL, manifest: object) + let entry = packageURL.appendingPathComponent("provider/main.mjs") + try FileManager.default.createDirectory(at: entry.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: entry) + let operations = DeclarativePluginTestSupport.operations + let descriptor = try operations.validate(manifest: Data(contentsOf: packageURL.appendingPathComponent("plugin.json"))) + let sessions = MacVcsProviderSessions( + launchResolver: MacPluginLanguageServerLaunchResolver( + planner: operations, + directories: MacPluginStateDirectories(rootURL: root.appendingPathComponent("state", isDirectory: true)), + settings: InMemoryPluginSettings() + ), + tools: VcsTestTools(), + versionChecker: operations, + runtime: VcsTestToolRuntime(), + core: RustCoreBridge(), + processRegistry: nil, + endBackendProcesses: { _, _ in } + ) + let target = VcsProviderTarget( + pluginID: descriptor.id.rawValue, vcs: "svn", manifest: descriptor.normalizedManifest, + packageURL: packageURL, workspaceURL: workspaceURL + ) + defer { sessions.retainProviders { _ in false } } + + do { + _ = try await sessions.negotiation(for: target) + Issue.record("The provider started without its VCS client") + } catch let failure as VcsProviderFailure { + #expect(failure.code == "vcs.tool.unavailable") + #expect(failure.message == VcsTestTools.NotFound().localizedDescription) + } + } + + @Test + func installReviewShowsTheProviderCommandClientAndNetworkUse() throws { + let review = try PluginMarketplaceTestSupport.evaluator.installReview( + manifest: VcsTestData.svnProviderManifest(), + expectedID: nil, expectedVersion: nil, approvedPrograms: nil + ) + let provider = try #require(review.programs.first { $0.kind == .runtimeVcsProvider }) + #expect(provider.vcs == "svn") + #expect(provider.tool == "node") + #expect(provider.vcsClientCommands == ["svn"]) + #expect(provider.mayUseNetwork) + + let description = PluginMarketplacePresentation.programDescription(provider, locale: Locale(identifier: "en")) + #expect(description.title == "svn provider (runs with node)") + let lines = description.detail.components(separatedBy: "\n") + #expect(lines.first?.hasPrefix("node ") == true, "\(lines)") + #expect(lines.first?.contains("provider/main.mjs") == true, "\(lines)") + #expect(lines.dropFirst() == ["Client: svn", "May connect to the repository server"]) + } +} + +/// The provider feature through the production app model: project tree +/// moves and the project window's teardown. +@Suite("VCS providers in the app model", .serialized) +@MainActor +struct VcsProviderAppModelTests { + /// A tracked file goes to the provider, an unversioned one stays a plain + /// disk move. + @Test + func projectTreeMovesGoThroughTheProviderThatTracksTheFile() async throws { + let root = try Self.makeWorkspace() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent("docs"), withIntermediateDirectories: true) + try Data("alpha".utf8).write(to: root.appendingPathComponent("a.txt")) + try Data("beta".utf8).write(to: root.appendingPathComponent("b.txt")) + func url(_ path: String) -> URL { root.appendingPathComponent(path).standardizedFileURL } + + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("b.txt", "unversioned")]) + // Like `svn move`, the provider moves the file itself. + fixture.providers.onFileOperation = { operation, rootPath in + guard case .move(let from, let to) = operation else { return } + let base = URL(fileURLWithPath: rootPath) + try FileManager.default.moveItem(at: base.appendingPathComponent(from), to: base.appendingPathComponent(to)) + } + let model = Self.makeModel(fixture) + defer { model.workspaceFeature.reset() } + + model.openProjectDirectly(root) + #expect(await awaitChange(on: model) { model.projectFiles.contains(url("b.txt")) }) + model.loadVcsProviders(at: root) + #expect(await fixture.settled()) + #expect(await awaitChange(on: fixture.feature) { fixture.feature.treeStatus.status(for: url("b.txt")) != nil }) + + await model.moveProjectItems([url("a.txt")], to: url("docs")) + #expect(fixture.providers.requests.contains(.init(operation: .move(from: "a.txt", to: "docs/a.txt"), root: root.path))) + #expect(FileManager.default.fileExists(atPath: url("docs/a.txt").path)) + #expect(!FileManager.default.fileExists(atPath: url("a.txt").path)) + + await model.moveProjectItems([url("b.txt")], to: url("docs")) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) + #expect(FileManager.default.fileExists(atPath: url("docs/b.txt").path)) + // Not tracking a file is no failure; the disk move reports as usual. + #expect(fixture.feature.failure == nil) + } + + /// Closing the native project window ends the session without closing + /// the workspace. The workspace's providers stop all the same, without + /// waiting for a status read still in flight, and a workspace load that + /// lands after the close does not start them again. + @Test + func closingTheProjectWindowStopsTheWorkspaceProviders() async throws { + let root = try Self.makeWorkspace() + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("a.txt").standardizedFileURL + try Data("alpha".utf8).write(to: file) + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + let model = Self.makeModel(fixture) + defer { model.workspaceFeature.reset() } + + model.openProjectDirectly(root) + #expect(await awaitChange(on: model) { model.projectFiles.contains(file) }) + model.loadVcsProviders(at: root) + #expect(await fixture.settled()) + #expect(fixture.providers.running.map(\.workspaceURL) == [root]) + let statusHeld = TestGate() + fixture.providers.holds["status"] = statusHeld + defer { statusHeld.open() } + let feature = fixture.feature + feature.refresh("") + #expect(feature.root("")?.isRefreshing == true) + + await model.shutdownProjectSession() + // A scan that was still loading the workspace finishes afterwards. + model.loadVcsProviders(at: root) + + #expect(fixture.providers.running.isEmpty) + #expect(feature.workspaceURL == nil) + #expect(feature.roots.isEmpty) + #expect(!feature.isDetecting) + } + + private static func makeWorkspace() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-model-\(UUID().uuidString)", isDirectory: true) + .standardizedFileURL + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeModel(_ fixture: VcsFeatureFixture) -> AppModel { + let store = VcsTestKeyValueStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, settings: settings, moduleLaunchMode: .safeMode, platformUI: VcsMoveTestUI() + ).services + services.vcsProviders = fixture.feature + return AppModel(settings: settings, services: services) + } +} + +// MARK: - Test doubles + +private enum VcsTestData { + static let pluginID = "dev.example.svn" + + /// The contract's Subversion provider manifest. The real plugin is a + /// community plugin in its own repository. + static func svnProviderManifest() throws -> Data { + try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json")) + } + + static func detection( + roots: [(id: String, vcs: String)], + workspaceURL: URL, + ambiguous: [String] = [], + unusedMappings: [VcsRootMapping] = [] + ) throws -> VcsDetection { + let object: [String: Any] = [ + "providers": [ + [ + "vcs": "svn", "displayName": "Subversion", + "origin": ["kind": "plugin", "pluginID": pluginID], + "markers": [["name": ".svn", "type": "directory"]], "metadataPaths": [".svn/wc.db"] + ], + [ + "vcs": "git", "displayName": "Git", "origin": ["kind": "builtIn"], + "markers": [["name": ".git", "type": "directory"]] + ] + ], + "roots": roots.map { root -> [String: Any] in + let path = root.id.isEmpty ? workspaceURL.path : workspaceURL.appendingPathComponent(root.id).path + return ["id": root.id, "vcs": root.vcs, "path": path, "mapped": false] + }, + "ambiguous": ambiguous.map { ["id": $0, "vcs": ["git", "svn"]] }, + "unusedMappings": unusedMappings.map { ["id": $0.id, "vcs": $0.vcs] }, + "truncated": false, "rejected": [], "conflicts": [] + ] + return try JSONDecoder().decode(VcsDetection.self, from: JSONSerialization.data(withJSONObject: object)) + } + + static func json(_ object: Any) throws -> ToolingJSONValue { + try JSONDecoder().decode(ToolingJSONValue.self, from: JSONSerialization.data(withJSONObject: object)) + } + + static func statusReply(_ changes: [(path: String, status: String)]) throws -> ToolingJSONValue { + try json([ + "head": [ + "revision": ["id": "7", "display": "r7", "kind": "number"], + "branch": "trunk", "location": "file:///repo/trunk" + ], + "changes": changes.map { ["path": $0.path, "oldPath": NSNull(), "status": $0.status, "flags": []] }, + "truncated": false + ]) + } +} + +/// Stands in for Rust Core: detection answers from a fixed value, ownership +/// follows Core's deepest-root rule, replies decode as sent. +private final class FakeVcsPlanner: VcsRootPlanning, @unchecked Sendable { + private let lock = NSLock() + private var storedDetection: VcsDetection + private var detections = 0 + private var mappings: [VcsRootMapping] = [] + private var hold: (paths: Set, entered: TestGate, released: TestGate)? + private var holdTimedOut = false + + init(detection: VcsDetection) { + storedDetection = detection + } + + var detection: VcsDetection { + get { lock.withLock { storedDetection } } + set { lock.withLock { storedDetection = newValue } } + } + + var detectCount: Int { lock.withLock { detections } } + var lastMappings: [VcsRootMapping] { lock.withLock { mappings } } + var ownersTimedOut: Bool { lock.withLock { holdTimedOut } } + + /// Ownership asked for any of `paths` opens `entered`, then answers once + /// `released` opens. + var heldOwners: (paths: Set, entered: TestGate, released: TestGate)? { + get { lock.withLock { hold } } + set { lock.withLock { hold = newValue } } + } + + func detectRoots(workspaceURL: URL, manifests: [Data], mappings: [VcsRootMapping]) throws -> VcsDetection { + lock.withLock { + detections += 1 + self.mappings = mappings + return storedDetection + } + } + + func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] { + if let hold = heldOwners, paths.contains(where: hold.paths.contains) { + hold.entered.open() + // This synchronous protocol runs on the feature's detached ownership task. + let released = hold.released.waitSynchronously(timeout: 5) + lock.withLock { holdTimedOut = holdTimedOut || !released } + } + func contains(_ id: String, _ path: String) -> Bool { + id.isEmpty || id.hasPrefix("..") || path == id || path.hasPrefix(id + "/") + } + func depth(_ id: String) -> Int { + id.isEmpty ? 0 : id.split(separator: "/").count * (id.hasPrefix("..") ? -1 : 1) + } + return paths.map { path in + let root = roots.filter { contains($0, path) }.max { depth($0) < depth($1) } + let directory = ambiguous.filter { contains($0, path) }.map(depth).max() + if let root, let directory, directory > depth(root) { return nil } + return root + } + } + + func decodeReply( + _ operation: VcsOperation, + capabilities: [String: Int], + reply: ToolingJSONValue, + as type: Value.Type + ) throws -> Value { + try JSONDecoder().decode(Value.self, from: JSONEncoder().encode(reply)) + } +} + +/// Provider processes that answer every capability from canned replies. +@MainActor +private final class FakeVcsProviders: VcsProviderRequesting { + struct Request: Equatable { + let operation: VcsOperation + let root: String + } + + var replies: [String: ToolingJSONValue] = [:] + var failures: [String: VcsProviderFailure] = [:] + /// Requests that wait for the gate before answering. + var holds: [String: TestGate] = [:] + var onFileOperation: ((VcsOperation, String) throws -> Void)? + private(set) var requests: [Request] = [] + private(set) var retainCount = 0 + /// Targets that answered a request and were not stopped since. + private(set) var running: Set = [] + + func statusCount(root: String) -> Int { + requests.filter { $0.operation.name == "status" && $0.root == root }.count + } + + func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation { + Self.negotiation(target.vcs) + } + + func send( + _ operation: VcsOperation, + root: String, + to target: VcsProviderTarget, + timeout: Duration + ) async throws -> VcsProviderReply { + requests.append(Request(operation: operation, root: root)) + running.insert(target) + if let hold = holds[operation.name] { + _ = await hold.waitUntilOpen(timeout: .seconds(5)) + } + if let failure = failures[operation.name] { throw failure } + if operation.name == "fileOperation" { try onFileOperation?(operation, root) } + return VcsProviderReply(value: replies[operation.name] ?? .object([:]), negotiation: Self.negotiation(target.vcs)) + } + + func retainProviders(_ keep: (VcsProviderTarget) -> Bool) { + retainCount += 1 + running = running.filter(keep) + } + + private static func negotiation(_ vcs: String) -> VcsProviderNegotiation { + VcsProviderNegotiation( + vcs: vcs, capabilities: Dictionary(uniqueKeysWithValues: VcsCapability.allCases.map { ($0.rawValue, 1) }) + ) + } +} + +/// Metadata directory watchers that deliver only what a test sends. +private final class FakeMetadataWatchers: DirectoryWatcherFactory, @unchecked Sendable { + final class Watcher: DirectoryChangeSource, @unchecked Sendable { + let directory: URL + let onChange: @Sendable (DirectoryChangeBatch) -> Void + private(set) var isRunning = false + + init(directory: URL, onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void) { + self.directory = directory + self.onChange = onChange + } + + func start() { isRunning = true } + func stop() { isRunning = false } + } + + private(set) var watchers: [Watcher] = [] + + /// The paths of the directories watched now. + var running: [String] { watchers.filter(\.isRunning).map(\.directory.path).sorted() } + + func make( + configuration: DirectoryWatchConfiguration, + visibilityRules: FileVisibilityRules, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) -> any DirectoryChangeSource { + let watcher = Watcher(directory: configuration.workspaceRoot, onChange: onChange) + watchers.append(watcher) + return watcher + } + + /// Delivers each path to the running watcher of the directory holding it. + func deliver(_ urls: [URL]) { + for watcher in watchers where watcher.isRunning { + let paths = urls.map(\.path).filter { $0.hasPrefix(watcher.directory.path + "/") } + if !paths.isEmpty { watcher.onChange(DirectoryChangeBatch(workspacePaths: paths)) } + } + } + + func deliver(_ batch: DirectoryChangeBatch) { + watchers.filter(\.isRunning).forEach { $0.onChange(batch) } + } +} + +/// A feature model over the fakes. The refresh delay passes at once and +/// the clock moves only when a test advances it. +@MainActor +private final class VcsFeatureFixture { + let workspaceURL: URL + let planner: FakeVcsPlanner + let providers = FakeVcsProviders() + let metadataWatchers = FakeMetadataWatchers() + let files = VcsTestFiles() + let keyValues = VcsTestKeyValueStore() + let clock = VcsTestClock() + let feature: VcsProvidersFeatureModel + + init( + workspaceURL: URL = URL(fileURLWithPath: "/virtual/workspace", isDirectory: true), + roots: [(id: String, vcs: String)], + ambiguous: [String] = [] + ) throws { + self.workspaceURL = workspaceURL.standardizedFileURL + planner = FakeVcsPlanner(detection: try VcsTestData.detection( + roots: roots, workspaceURL: self.workspaceURL, ambiguous: ambiguous + )) + let clock = clock + let files = files + feature = VcsProvidersFeatureModel( + planner: planner, + providers: providers, + mappingStore: MacVcsRootMappingStore(store: keyValues), + fileExists: { files.exists($0) }, + sleep: { _ in try Task.checkCancellation() }, + now: { clock.now }, + metadataWatcherFactory: metadataWatchers, + sources: { + [VcsProviderPluginSource( + pluginID: VcsTestData.pluginID, manifest: Data("{}".utf8), + packageURL: URL(fileURLWithPath: "/virtual/plugin", isDirectory: true), + providers: [DeclarativeVcsProvider(id: "svn", displayName: "Subversion")] + )] + } + ) + } + + func url(_ path: String) -> URL { + workspaceURL.appendingPathComponent(path).standardizedFileURL + } + + /// Reports changed workspace entries, as the workspace watcher does. + func changed(_ paths: String...) { + feature.workspaceFilesChanged(paths.map { WorkspaceFileChange(fileURL: url($0), kind: .changed) }) + } + + /// Reports created workspace entries, as the workspace watcher does. + func created(_ paths: String...) { + feature.workspaceFilesChanged(paths.map { WorkspaceFileChange(fileURL: url($0), kind: .created) }) + } + + /// Opens the workspace and waits until every root has read its status. + func open() async -> Bool { + feature.reset(workspaceURL: workspaceURL) + return await settled() + } + + /// Waits until detection and every status read have finished and + /// `condition` holds, so a refresh that should not happen would have + /// been requested by then. + func settled(_ condition: @escaping @MainActor @Sendable () -> Bool = { true }) async -> Bool { + await awaitChange(on: feature) { [feature] in + condition() && !feature.isDetecting && !feature.roots.isEmpty + && feature.roots.allSatisfy { $0.snapshot != nil && !$0.isRefreshing } + } + } + + func close() { + feature.reset(workspaceURL: nil) + } +} + +/// Finds the provider's runtime but no VCS client. +private final class VcsTestTools: BuildToolResolving { + struct NotFound: LocalizedError { + var errorDescription: String? { "svn was not found." } + } + + func resolve( + _ invocation: BuildInvocation, displayName _: String, workspaceURL _: URL, + checkVersion _: @escaping @Sendable (String?, String) throws -> BuildVersionCheck + ) async throws -> ResolvedBuildTool { + guard invocation.executables == [.runtime("node")] else { throw NotFound() } + return ResolvedBuildTool( + executableURL: URL(fileURLWithPath: "/usr/bin/false"), environment: [:], executesProjectCode: false, version: nil + ) + } +} + +private final class VcsTestToolRuntime: LanguageToolRuntimePort { + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools _: [String]) -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { name } +} + +/// The paths a marker lookup finds, and the lookups made. +private final class VcsTestFiles: @unchecked Sendable { + private let lock = NSLock() + private var existing: Set = [] + private var recorded: [String] = [] + private var waiter: (count: Int, gate: TestGate)? + + var lookups: [String] { lock.withLock { recorded } } + + func add(_ urls: URL...) { + lock.withLock { existing.formUnion(urls.map(\.path)) } + } + + /// A gate that opens once `count` lookups have been made. + func gate(afterLookups count: Int) -> TestGate { + let gate = TestGate() + let reached = lock.withLock { + waiter = (count, gate) + return recorded.count >= count + } + if reached { gate.open() } + return gate + } + + func exists(_ url: URL) -> Bool { + let (found, gate) = lock.withLock { () -> (Bool, TestGate?) in + recorded.append(url.path) + let gate = waiter.flatMap { recorded.count >= $0.count ? $0.gate : nil } + return (existing.contains(url.path), gate) + } + gate?.open() + return found + } +} + +private final class VcsTestClock: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock.now + + var now: ContinuousClock.Instant { lock.withLock { instant } } + + func advance(by duration: Duration) { + lock.withLock { instant += duration } + } +} + +private final class VcsTestKeyValueStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + var isEmpty: Bool { values.isEmpty } + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} + +private final class VcsMoveTestUI: PlatformUI { + func copyFilesToClipboard(_ urls: [URL]) -> Bool { false } + func fileURLsFromClipboard() -> [URL] { [] } + func activateApplication() {} + func chooseDirectory(title: String, prompt: String) -> URL? { nil } + func chooseFile(title: String, prompt: String) -> URL? { nil } + func revealInFileBrowser(_ url: URL) {} + func open(_ url: URL) {} + func copyToClipboard(_ value: String) {} + func markdownImageFromClipboard() -> MarkdownImageSource? { nil } + func startAccessingProject(_ url: URL) -> Bool { false } + func stopAccessingProject(_ url: URL) {} +} diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index 33f3d28e7b..c9eaed5ac5 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; /// Default workspace exclusions, also used by Git discovery for generated directories. pub(crate) const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ ".git", + ".svn", ".worktree", ".worktrees", ".build", @@ -439,7 +440,7 @@ pub fn search_everywhere(request: SearchRequest) -> Result Date: Fri, 23 Oct 2026 21:55:43 +0800 Subject: [PATCH 11/50] =?UTF-8?q?fix(macos):=20=E4=BF=AE=E6=AD=A3=20Agent?= =?UTF-8?q?=20=E6=AD=A3=E6=96=87=E7=95=99=E7=99=BD=E4=B8=8E=20AI=20?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=8E=92=E5=BA=8F=20(#1450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-09-25-shared-acp-agent-conversation.md | 4 +-- .../2026-10-15-macos-settings-hierarchy.md | 4 ++- .../AppModel/AppModelSupportTypes.swift | 2 +- .../Views/Agent/AgentTranscriptView.swift | 3 ++- .../LitheTests/AgentTranscriptReplay.swift | 25 ++++++++++++++++--- .../SettingsSidebarLayoutTests.swift | 2 +- .../features/agent-chat-editor-placement.json | 7 ++++-- .../features/application-settings.json | 4 +-- 8 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index fbd9b412cc..8d086a29b2 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -116,7 +116,7 @@ Markdown 表格的表头和正文必须完整展示:长内容在单元格内 转录行抽成按数据比较的 `AgentTranscriptRows`:外层视图会随额度、会话列表、统计等任意发布重新求值,行列表只在消息、已完成轮次、搜索词、推理展开状态变化时才重新分组和比对,滚动时不再被无关发布打断。工具调用参照 Claude 的展示:转录里是一行浅色摘要(按 ACP `kind` 统计“运行了 N 个命令,读取了 N 个文件”),展开为圆角列表,每行一个调用、点开看证据;长列表先显示 8 行。转录内的工具组不再嵌套纵向滚动视图(每组一个 NSScrollView 是滚动卡顿来源),只有活动弹窗保留四行高度的内部视口。订阅登录不经过本地代理,上下文明细和模型调用统计的说明在订阅模式下改为解释原因,而不是提示开启设置。 -编辑区内消息内容与输入区共用 80% 居中内容列,输入框的左右 8pt 内边距包含在列内。`AgentEditorColumn` 通过 SwiftUI 的局部布局协议直接从父级尺寸计算列宽和居中位置,不把连续缩放结果写入状态;标题栏和消息滚动容器保持面板整宽,原生滚动条仍在面板右缘。消息滚动区会扣除原生滚动条自身的占位,因此列宽以各自实际可用宽度为准;原生回归从 SwiftUI 内容文档测量列宽与居中位置,不能用可能带额外留白的裁剪视口宽度替代。未配置页也只约束正文与输入框,标题工具栏保留整宽,否则其最小宽度会把窄列撑偏。工具窗口仍使用自身可用宽度,并为右侧导航保留间距。固定 716pt 上限和固定左右 90pt 留白均被否决:用户要求列宽随窗口按比例变化,宽窗口不应留下过多空白。80% 来自用户明确的构图要求,不是 Community 公开 AI 插件常量;代价是超宽窗口的文字行会随面板增长。 +编辑区内消息内容与输入区共用 80% 居中内容列,输入框的左右 8pt 内边距包含在列内。`AgentEditorColumn` 通过 SwiftUI 的局部布局协议直接从父级尺寸计算列宽和居中位置,不把连续缩放结果写入状态;标题栏和消息滚动容器保持面板整宽,原生滚动条仍在面板右缘。消息滚动区会扣除原生滚动条自身的占位,因此列宽以各自实际可用宽度为准;原生回归从 SwiftUI 内容文档测量列宽与居中位置,不能用可能带额外留白的裁剪视口宽度替代。未配置页也只约束正文与输入框,标题工具栏保留整宽,否则其最小宽度会把窄列撑偏。工具窗口仍使用自身可用宽度,正文左侧只保留 12pt 基础边距,额外 28pt 只留在右侧供轮次导航使用;不要把右侧导航留白对称加到左侧,否则窄面板会白白损失正文宽度。固定 716pt 上限和固定左右 90pt 留白均被否决:用户要求列宽随窗口按比例变化,宽窗口不应留下过多空白。80% 来自用户明确的构图要求,不是 Community 公开 AI 插件常量;代价是超宽窗口的文字行会随面板增长。 切换图标使用 Community 915c3c26 的 expui/general/previewVertically.svg 及 dark 变体,16×16;按钮尺寸、选中/hover 与工具提示复用共享 owner。图标作为固定构建资源随包只读发布,不引入运行时下载或缓存。Community 提供通用标签/图标/工具窗口规则,AI 插件的聊天迁移页面未在公开仓库找到,不把当前页面组合与字号布局冒充它的私有源码常量。 @@ -424,7 +424,7 @@ Windows 连接桥已接入共享 Host,面板 UI 也已落地(活动栏入口 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentConversationPresentationTests|EditorTabOrderFeatureModelTests.*editorChatCenters'`:在生产输入组件中改变草稿,验证默认两行、四行自然增长、长草稿封顶且末尾可滚至、附件增高及删除/清空后收缩;同一原生窗口从 1100pt 缩至 320pt 后按新宽度换行,工具窗口和编辑页分别覆盖。布局等待只驱动原生事务并观察最终尺寸,每个阶段最多两秒,窗口和内容通过 `defer` 释放。列宽回归在 320/900/1400pt 下检查真实底栏位置,要求编辑页为 80% 居中列、工具窗口使用可用宽度。 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentTranscriptNavigationTests|AgentTranscriptScrollTests'`:真实原生会话视图在独立子进程中驱动四轮溢出历史、工具更新、编辑/执行权限、最终统计与窄宽窗口。宽编辑页另检查滚动容器到达面板右边缘,触发实际轮次导航按钮后首条消息进入视口,阅读列保持可用宽度的 80%;原生视口测试覆盖两轮同时可见、行卸载和已经排队的底部跟随取消。验证主动浏览不被拉走,权限卡片不遮住最新回复,回到底部后继续跟随;子进程有八秒期限,布局死循环也能由父进程结束并报告最后阶段。回放使用独立偏好域并隐藏详情栏,避免用户保存的布局改变消息面板边缘断言;辅助功能测试只启用当前进程的界面树并恢复原值。直接启动当前测试运行器并继承其环境,不能经 `/usr/bin/env` 中转,否则系统可能清除 XCTest 所需的动态库路径。最终窗口宽度变更后的底部检查最多等待两秒的原生布局事务,仍要求差距小于2pt,不强制滚动或放宽精度。完整工作区双窗口回放额外覆盖原卡点与连续面板宽度变化,不把合成事件回放等同于真实模型端到端通过。macOS 13 的源码兼容性与最低部署目标保留,旧系统运行仍须在对应系统复验。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentTranscriptNavigationTests|AgentTranscriptScrollTests'`:真实原生会话视图在独立子进程中驱动四轮溢出历史、工具更新、编辑/执行权限、最终统计与窄宽窗口。宽编辑页另检查滚动容器到达面板右边缘,触发实际轮次导航按钮后首条消息进入视口,阅读列保持可用宽度的 80%;原生视口测试覆盖两轮同时可见、行卸载和已经排队的底部跟随取消。工具窗口在深浅主题及 280/350/486/620pt 连续缩放中检查实际消息行边界,要求左侧 12pt、右侧 40pt,让正文用上左侧回收的空间并保留导航余量。验证主动浏览不被拉走,权限卡片不遮住最新回复,回到底部后继续跟随;子进程有八秒期限,布局死循环也能由父进程结束并报告最后阶段。回放使用独立偏好域并隐藏详情栏,避免用户保存的布局改变消息面板边缘断言;辅助功能测试只启用当前进程的界面树并恢复原值。直接启动当前测试运行器并继承其环境,不能经 `/usr/bin/env` 中转,否则系统可能清除 XCTest 所需的动态库路径。最终窗口宽度变更后的底部检查最多等待两秒的原生布局事务,仍要求差距小于2pt,不强制滚动或放宽精度。完整工作区双窗口回放额外覆盖原卡点与连续面板宽度变化,不把合成事件回放等同于真实模型端到端通过。macOS 13 的源码兼容性与最低部署目标保留,旧系统运行仍须在对应系统复验。 - `node --test scripts/test-codex-config-relay.mjs`:用受控协议流及进程替身验证创建/恢复/分叉配置、不受点号覆盖影响、其他供应商和工具消息照传、UTF-8 拆包、原生轮次与错误信息、格式损坏的有界停止以及 Windows npm cmd 的启动分支。同时验证恢复票据、真实原生快照、累计量不冒充占用、未知/失败保留原恢复路径、订阅重试不变。此测试进入两端 CI;模拟 Windows 分支不等于 Windows 原生运行验收。 diff --git a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md index 24225efae9..6e949e69b4 100644 --- a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md +++ b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md @@ -26,6 +26,8 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、 插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。 +AI 分组内按“AI 服务商 → Agent 管理 → 指令 → 技能 → MCP 配置 → AI 与提交”排列:先放基础配置,再放行为规则和扩展能力,最后放具体应用功能。顺序仍只在 `SettingsGroup.categories` 中声明,搜索过滤后的侧栏也沿用这一顺序。 + ### 插件注册的页面 插件可以在清单里把设置和按钮放到“语言与框架 › <语言>”或“构建、执行、部署 › <构建系统>”。这些页面不是 `SettingsCategory` 的 case,而是由 `PluginSettingsRegistry` 从已启用插件的声明推导,跟在对应分组的静态页面后面;面包屑和搜索同样从注册结果推导。宿主不为任何具体语言或构建工具写专用页面,规则见 [插件设置的通用注册面与生效方式](../architecture/2026-10-21-plugin-settings-surfaces.md)。 @@ -59,7 +61,7 @@ Issue 希望“构建工具 → Maven”“服务运行”“框架支持”也 ## 验证 -- `SettingsSidebarLayoutTests` 保证每个页面恰好属于一个分组或是一级页面,并固定本表的分组内容。 +- `SettingsSidebarLayoutTests` 保证每个页面恰好属于一个分组或是一级页面,并固定本表的分组内容与 AI 页面顺序。 - `SettingsSearchVocabularyTests` 保证每个页面仍能被检索到。 - `./scripts/test-macos.sh --filter SettingsSidebarLayoutTests`。 - 实机查看设置窗口的侧栏、面包屑、搜索展开和从其他入口跳转到某一页时展开所属分组。 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index a628f5cbb0..ebe5ad417f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -82,7 +82,7 @@ enum SettingsGroup: String, CaseIterable, Identifiable { case .build: [.project, .run] case .languages: [.lsp] case .tools: [.terminal, .diagnostics] - case .ai: [.mcp, .ai, .instructions, .agents, .skills, .providers] + case .ai: [.providers, .agents, .instructions, .skills, .mcp, .ai] } } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift index 2afff32822..4e50852d37 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift @@ -211,7 +211,8 @@ struct AgentTranscriptView: View { .padding(.horizontal, 12) .padding(.vertical, 12) .modifier(AgentEditorColumn(isEditor: isEditorLayout)) - .padding(.horizontal, isEditorLayout ? 0 : 28) + // Only the trailing navigation rail needs the extra gutter. + .padding(.trailing, isEditorLayout ? 0 : 28) .background(AgentTranscriptNavigationViewport(state: navigation)) .litheScrollViewChrome(alwaysShowVertical: true) .background(AgentTranscriptScrollAnchor( diff --git a/macos/Tests/LitheTests/AgentTranscriptReplay.swift b/macos/Tests/LitheTests/AgentTranscriptReplay.swift index 98c66c6c6e..0eef220899 100644 --- a/macos/Tests/LitheTests/AgentTranscriptReplay.swift +++ b/macos/Tests/LitheTests/AgentTranscriptReplay.swift @@ -151,9 +151,27 @@ final class TranscriptReplay { try update(["sessionUpdate": "agent_message_chunk", "content": ["type": "text", "text": "Changes complete.\n\n```text\nTEST_PASSED\n```"]]) try finish() - for value in [280.0, 350, 620, 350] { - window.setContentSize(NSSize(width: value, height: 600)) - try await layout("finished-width-\(value)") + for (theme, appearance) in [("light", NSAppearance.Name.aqua), ("dark", .darkAqua)] { + window.appearance = NSAppearance(named: appearance) + for value in [280.0, 350, 486, 620, 350] { + window.setContentSize(NSSize(width: value, height: 600)) + try await layout("finished-\(theme)-width-\(value)") + if !navigating { + guard let document = scroll.documentView else { throw ReplayError.missingScrollView } + let anchors = navigationAnchors(in: host) + guard !anchors.isEmpty else { throw ReplayError.missingReply } + // A narrow tool window must use the recovered leading space + // while retaining the trailing clearance for navigation. + for anchor in anchors { + let row = anchor.convert(anchor.bounds, to: document) + guard abs(row.minX - 12) < 1, + abs(document.bounds.maxX - row.maxX - 40) < 1 else { + throw ReplayError.messageColumnDoesNotFit(row: row, document: document.bounds) + } + } + try capture("tool-window-\(theme)-width-\(value)") + } + } } guard feature.selectedConversation?.completedTurns.count == 5, feature.selectedConversation?.messages.last?.text.contains("TEST_PASSED") == true else { @@ -273,5 +291,6 @@ private final class ReplayConnection: AgentConnection { private enum ReplayError: Error { case missingSession, missingPermission, remainingPermission, missingNotice, remainingNotice, missingReply, invalidCommand, missingScrollView, historyPositionWasLost case scrollbarWasNotAtEdge, navigationFailed(stage: String) + case messageColumnDoesNotFit(row: CGRect, document: CGRect) case latestReplyWasNotVisible(document: CGFloat, viewport: CGFloat, frame: CGFloat, inset: CGFloat) } diff --git a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift index 333ef1800a..a36a0a8f1e 100644 --- a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift +++ b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift @@ -37,6 +37,6 @@ struct SettingsSidebarLayoutTests { #expect(SettingsGroup.build.categories == [.project, .run]) #expect(SettingsGroup.languages.categories == [.lsp]) #expect(SettingsGroup.tools.categories == [.terminal, .diagnostics]) - #expect(SettingsGroup.ai.categories == [.mcp, .ai, .instructions, .agents, .skills, .providers]) + #expect(SettingsGroup.ai.categories == [.providers, .agents, .instructions, .skills, .mcp, .ai]) } } diff --git a/shared/platform-feature-matrix/features/agent-chat-editor-placement.json b/shared/platform-feature-matrix/features/agent-chat-editor-placement.json index 72a89a7372..8bc2a18d44 100644 --- a/shared/platform-feature-matrix/features/agent-chat-editor-placement.json +++ b/shared/platform-feature-matrix/features/agent-chat-editor-placement.json @@ -19,7 +19,10 @@ "macos/Sources/Lithe/Views/Workbench/WorkbenchToolWindowDock.swift", "macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift", "macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift", - "macos/Tests/LitheTests/ToolWindowDockingTests.swift" + "macos/Tests/LitheTests/ToolWindowDockingTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", + "macos/Tests/LitheTests/AgentTranscriptReplay.swift", + "macos/Tests/LitheTests/AgentTranscriptScrollTests.swift" ] }, "windows": { @@ -40,5 +43,5 @@ ] }, "owner": "Agent", - "verification": "深浅主题下使用标题栏位置按钮、工具窗口图标拖放和右键菜单在编辑区与工具窗口之间移动同一会话;重复打开仅聚焦,不创建会话或重连,文字与附件草稿保持。历史按钮进入原搜索/收藏/改名/可恢复移除/导出管理页,选择原连接会话后返回聊天。关闭或反向位置按钮返回工具窗口,共享标签排序、分组和焦点语义不变。两种位置共用单行标题、输入框上方按内容显示紧凑活动摘要、框内默认两行书写区,没有顶部条或占位;附件“+”在底栏Agent图标左侧,同栏保留审批/模型选择和靠右的订阅额度、发送旁唯一会话详情入口;收到用量显示圆环,未上报显示信息图标,入口位置不变。编辑区聊天列最大716pt、输入框最大700pt,窄窗口自然收缩,宽编辑页可通过设置菜单显示右侧上下文构成与会话统计详情;窄窗口仍可通过发送旁同一入口在可滚动弹窗查看相同数据,顶部不重复显示信息按钮。验证320/417/900/1400pt和深浅主题、各项摘要详情及文件回撤、上下文折叠、统计持久化、复制消息/对话、隐藏工具栏与会话切换的弹层清理。会话详情弹窗与右侧详情栏均不展示更改区、Git分支、变更文件数、Agent编辑行数或未编辑文件提示;文件活动栏及回撤仍可用;MCP服务器清单没有后端数据时不编造;本次原生宿主验证不能替代真实Agent完整产品验收。两种位置共用按内容出现的紧凑活动摘要:任务/子代理一行、文件修改摘要一行;空态移除状态区和间距,输入框与正文位置及最小高度正确,计划清空、保留全部和切换会话关闭旧弹层。窄面板长标题不覆盖子代理入口,文件摘要继续打开原有差异、保留与回撤。 Windows 已实现编辑区标签与工具窗口之间的放置切换:编辑区使用最大 716pt 居中列、最大 700pt 输入框和可开关的会话详情栏;工具窗口在编辑区占用会话时转为历史列表,选择历史会话会聚焦其已有标签或新开标签。同一会话重复打开只聚焦现有标签,关闭标签或反向位置按钮返回工具窗口,未发送草稿与附件按会话共享。本次原生宿主验证不能替代真实Agent完整产品验收,Windows 真机验收待执行。 Windows:A 工作区编辑区会话保留挂载,切换 B 并连接 Agent 后再返回 A,侧栏仍从 A 的 buffers 判断会话已放置,不重复出现输入框;切换 Agent 后仅当前 Agent 的标签接管侧栏。" + "verification": "深浅主题下使用标题栏位置按钮、工具窗口图标拖放和右键菜单在编辑区与工具窗口之间移动同一会话;重复打开仅聚焦,不创建会话或重连,文字与附件草稿保持。历史按钮进入原搜索/收藏/改名/可恢复移除/导出管理页,选择原连接会话后返回聊天。关闭或反向位置按钮返回工具窗口,共享标签排序、分组和焦点语义不变。两种位置共用单行标题、输入框上方按内容显示紧凑活动摘要、框内默认两行书写区,没有顶部条或占位;附件“+”在底栏Agent图标左侧,同栏保留审批/模型选择和靠右的订阅额度、发送旁唯一会话详情入口;收到用量显示圆环,未上报显示信息图标,入口位置不变。编辑区聊天列最大716pt、输入框最大700pt,窄窗口自然收缩,宽编辑页可通过设置菜单显示右侧上下文构成与会话统计详情;窄窗口仍可通过发送旁同一入口在可滚动弹窗查看相同数据,顶部不重复显示信息按钮。验证320/417/900/1400pt和深浅主题、各项摘要详情及文件回撤、上下文折叠、统计持久化、复制消息/对话、隐藏工具栏与会话切换的弹层清理。会话详情弹窗与右侧详情栏均不展示更改区、Git分支、变更文件数、Agent编辑行数或未编辑文件提示;文件活动栏及回撤仍可用;MCP服务器清单没有后端数据时不编造;本次原生宿主验证不能替代真实Agent完整产品验收。两种位置共用按内容出现的紧凑活动摘要:任务/子代理一行、文件修改摘要一行;空态移除状态区和间距,输入框与正文位置及最小高度正确,计划清空、保留全部和切换会话关闭旧弹层。窄面板长标题不覆盖子代理入口,文件摘要继续打开原有差异、保留与回撤。 Windows 已实现编辑区标签与工具窗口之间的放置切换:编辑区使用最大 716pt 居中列、最大 700pt 输入框和可开关的会话详情栏;工具窗口在编辑区占用会话时转为历史列表,选择历史会话会聚焦其已有标签或新开标签。同一会话重复打开只聚焦现有标签,关闭标签或反向位置按钮返回工具窗口,未发送草稿与附件按会话共享。本次原生宿主验证不能替代真实Agent完整产品验收,Windows 真机验收待执行。 Windows:A 工作区编辑区会话保留挂载,切换 B 并连接 Agent 后再返回 A,侧栏仍从 A 的 buffers 判断会话已放置,不重复出现输入框;切换 Agent 后仅当前 Agent 的标签接管侧栏。 macOS 工具窗口正文在深浅主题及 280/350/486/620pt 缩放时,左侧仅保留 12pt 基础边距,额外导航留白只在右侧(合计 40pt);检查文字随可用宽度换行,右侧导航不覆盖正文,原生滚动条仍位于面板边缘,浏览历史和最新回复跟随继续正常,编辑页 80% 居中列保持原有行为。" } diff --git a/shared/platform-feature-matrix/features/application-settings.json b/shared/platform-feature-matrix/features/application-settings.json index 99d293d0fd..1ca65eda29 100644 --- a/shared/platform-feature-matrix/features/application-settings.json +++ b/shared/platform-feature-matrix/features/application-settings.json @@ -83,6 +83,6 @@ "verificationStatus": "pending" }, "owner": "Settings", - "notes": "Git 标签继承回显使用 Core 通过 Git 布尔解析器归一化的 pruneTags 值,识别 yes/on/1 及 false 等等价写法;远程设置覆盖全局默认,原始编辑值保持不变。 Windows 当前设置面板的 Editor → Display 提供显示缩略图开关,复用 showMinimap 和 Monaco 实时选项更新。无已保存 showMinimap 配置时默认关闭;已有用户保存的 true 或 false 保留,恢复默认设置也会关闭。macOS 默认值不变。 Windows字体目录使用DirectWrite提供系统及用户字体的规范名、本地化标签与等宽信息;编辑器可选择非等宽字体,目录失败不替换已保存选择,字号6–72与连字设置即时持久化。 Windows设置页通过SettingsSelect统一固定与可搜索下拉框的触发密度和菜单样式,包含运行配置、Git与AI提交;文本、多行文本与复选框复用现有共享控件,空字符串自动配置保持选中态。 macOS设置页把纯解释性说明收进标签后的 ⓘ 提示:默认只显示设置标签和图标,指针停留半秒后显示说明,点击图标可固定,键盘聚焦后用 Space/Return 打开;错误、警告、保存结果、空态和动态状态仍常驻显示。分组级说明复用 LitheSettingsSection 的 accessory,行级说明复用 LitheSettingsLabel 与 LitheInfoTip。 macOS设置行采用标签在左、控件在右的两列布局,分组标题到内容间距 8 pt、分组上方 10 pt、页面级间距 10 pt;编辑器字体页以当前字体的示例代码实时预览,切换字体后旧字体保留约 2.5 秒与新字体上下对比再淡出,减少动态效果开启时取消动画;插件声明的设置摘要同样收进 ⓘ。 macOS Git 行为配置行优先把设置名、当前生效值、下拉框和重置/应用按钮放在同一行,下拉框按该设置最长选项文案在 150–240 pt 内取宽;窗口过窄时当前生效值移到第二行,按钮保持完整。 macOS 项目 JDK 与 Maven 的生效运行时使用卡片展示模式、来源、名称和可选择复制的路径,警告/错误沿用同一卡片形状;Git 身份输入框回显当前生效值,保存按钮仅在草稿变化后启用;Maven 的 settings.xml 与 Local Repository 未覆盖时以实际有效路径作为占位文本,没有时显示 Not found;Agent 预检把版本、路径和安装来源拆成结构化小表,PASS/WARN/FAIL 使用带图标胶囊;MCP 页改为状态卡、项目权限和 Agent 配置分区。 LitheInfoTip 的悬停计时器不再触发页面重新求值,弹层锚点只在说明打开时安装。 项目JDK与Maven路径候选菜单复用SettingsPathInput,保留任意路径直接输入的草稿。 Windows设置对齐macOS分类树、双语检索、面包屑和标题分组线;侧栏默认234px,视口不超过768px时最小180px,否则最小234px;正文最小500px,拖动结束持久化布局。插件页复用既有插件管理且即时操作;普通设置即时保存;底部左侧恢复默认、右侧单个关闭,关闭不回滚已保存修改,项目及运行配置继续显式保存。Windows的AI服务商配置继续由既有AI与AI提交页面负责。 插件设置使用独立可调整的列表/详情分栏,列表最小200px、详情最小160px,窄设置窗口保留详情;原扩展管理入口保留网格布局。 Windows共用设置的中英文文案按macOS设置源码与中文本地化统一,覆盖通用、字体、Code Vision、标签布局、Tab宽度、Git、AI术语、搜索空态与相关命令入口;项目附加和Windows凭据说明保留平台实际语义,底部仍为单个关闭。 Windows跟随系统通过Tauri set_theme(None)解除原生窗口的手动主题覆盖,保留Windows主题事件向WebView2传递系统颜色偏好;复用既有媒体查询同步应用与编辑器,手动模式清理监听。通用页显示实际生效的主题,并按主题元数据判断明暗。 Windows设置检索仅收录当前实际页面字段,始终支持中英文标签;排除旧索引中的不可见控件。插件设置挂载保留全局搜索焦点,独立插件入口继续自动聚焦。Windows设置组件采用settings-panels中性命名;分层、主题、下拉、更新和插件焦点回归已接入Windows CI独立测试进程。 Windows路径输入在候选关闭时按Esc保留已编辑路径,手动清空仍可切回自动配置;搜索无结果时隐藏当前页面并保留未保存草稿,清空搜索恢复显示。 Windows路径候选回归使用非空候选和真实Combobox,覆盖方向键/Enter选择、未列出路径、弹层打开和关闭时Esc保留草稿、再次编辑与清空;候选行使用稳定路径key。 macOS设置侧栏收束为外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制、构建/执行/部署、语言与框架、工具(终端、诊断)、AI(MCP配置、AI与提交、Agents、AI服务商)七个一级入口,分组关系由SettingsGroup单一来源提供;Windows设置侧栏已同步为同样的七个一级入口:外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制(Git)、构建/执行/部署(项目环境、运行配置、Maven)、语言与框架(LSP)、工具(终端、日志)、AI(MCP配置、AI与提交、AI聊天与编辑);分组由settingsGroups单一来源提供,Windows没有独立AI服务商页和Agents页,不新增空页面,页面内容、存储键和保存时机不变。Windows 设置对话框保留入场模糊淡入,但通过 motion 的 transitionEnd 在入场结束后把插值终值覆盖成 filter: none(overlayEntranceSelfClearing,由 clearEntranceFilter 选择),避免弹窗因为常驻 filter 一直留在滤镜合成层;该层会关闭子像素抗锯齿并按显示缩放比栅格化,让整个弹窗(标题栏、分类栏、正文和底部按钮)一起发虚。该行为只对设置对话框启用,其它弹窗保持原样。 macOS Git 执行页在覆盖为空时回显实际解析到的 Git 路径与版本,并用 `git.executable` 在不打开仓库的情况下完成解析;覆盖路径不可运行时显示警告。Git 行为配置中“Lithe: 获取标签”为继承时,会按 `remote..tagOpt`、`fetch.pruneTags` 与 Lithe 的 prune 选择解析成具体标签传输行为(全部、不获取、同步删除、获取可达提交的标签,或多个远程不一致),不再只显示“遵循 Git 标签规则”。 macOS说明弹层的延迟创建锚点进入窗口后会重试显示,修复悬停或点击仅变蓝而无说明的问题;离开窗口仍取消待显示任务并关闭弹层。 macOS 隐藏路径合并为一个模式列表(原“目录”与“文件模式”两个框),应用按钮在右下,内置规则以只读胶囊回显、始终生效不可移除;语法是不区分大小写的通配符(正则的简化写法),说明与示例收进 ⓘ。文件夹必须用显式模式隐藏:`name/`、`name/*`、`name/**` 都表示从项目根起的文件夹规则,文件夹连同内容一起隐藏并在列目录时剪枝、不读取其内容;开头的 `**/` 代表零层或多层目录,因此 `**/name/` 同时覆盖根目录与任意层级的副本;纯名称和带通配符的文件 glob 仍只匹配文件(规则由 shared/fixtures/search/exclusions.json 固定)。旧版保存的两个列表在升级时合并:自定义目录名变为 `**/名称/`,文件模式原样保留。macOS 把工作区根下的 `.lithe` 作为唯一特例:被规则隐藏后不在项目树、搜索和列目录中显示,但文件监听仍送达其事件,编辑其中的 JSON 仍会重载项目服务(内置噪声如 `.lithe/run/local.json` 仍丢弃)。Windows 隐藏路径沿用既有 gitignore 语义,不在此次范围。", - "verification": "运行 ./scripts/test-macos.sh --filter GitSettingsPresentationTests,并以 cargo test --manifest-path rust/Cargo.toml -p lithe-core git::configuration:: 验证合法布尔写法、无值与空值、远程覆盖、无效值保留和编辑原值不变。 macOS 共享下拉选择器闭合框遵循 Islands:深色底 #26282C、浅色白底,1pt 普通边框、2pt 展开或键盘聚焦描边,28pt 高及 4pt 圆角;验证设置页与 Commit 的深浅外观、键盘聚焦及选择功能。Windows 无已保存缩略图配置时确认代码编辑器右侧不显示缩略图,设置 → Editor → Display 的显示缩略图开关为关闭;切换开关确认所有打开的编辑器即时更新,关闭设置重开及应用重启后选择保持,已有 showMinimap=true/false 的设置均保留,恢复默认后关闭;中英文显示对应文案。修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 Windows 编辑器字号输入数字居中,输入区到独立步进按钮间隔 8px,标准按钮命中范围 32×32px;手动输入、增减、上下限、只读和禁用正确。检查行高、缩进等共用数值控件的布局,并在深浅色设置页验收。 从实际 Windows 设置窗口进入 Editor → Display,确认 Font size 使用共享数值控件,增减与直接输入写入 editor.fontSize,6–72 上下限生效;检查此真实页面的居中、按钮大小及深浅色,避免只验证旧设置页或孤立组件。 步进按钮横排在输入框右侧,左侧增大、右侧减小,各保留 32×32px 点击区域和 4px 间距,输入框与按钮组垂直居中;键盘 Tab 顺序与左右顺序一致。 Windows验证中文与英文目录检索、非等宽字体、目录加载中及失败时的已存选择、共享字号步进控件边界、连字预览以及所有打开编辑器的即时更新。 Windows逐一打开所有设置分类及运行配置编辑器,检查深浅主题的下拉按钮、菜单圆角、边框、选中项与禁用态一致;验证字体和模型搜索、选项键盘选择、Esc仅关闭菜单、点击外部关闭、长列表滚动、标签可访问名称、切换到空字符串自动配置和关闭重开后的持久化;AI提交数值草稿仅在真实失焦或Enter失焦后保存,Maven路径保留显式应用。 项目路径候选菜单检查深浅色、候选选择、任意路径输入后直接点击保存和清空恢复自动配置,确认输入草稿不会被候选列表替换。 Windows逐一访问外观与行为、版本控制、构建/执行/部署、语言与框架、工具、AI六个分组及插件一级页面;中文字段与英文关键词搜索应定位正确页面并展开父组,无结果显示空态,Esc清空查询,外部重复请求同一页清空查询并重新定位。确认第一组标题、水平线、左对齐行、说明和底部左侧恢复默认、右侧只有关闭;中英文分别显示关闭和 Close,点击关闭后即时保存的字号等仍保留,未显式保存的项目草稿丢弃。拖动侧栏时不得连续写入布局,松开后保存,关闭重开或刷新后恢复;720px、768px、769px及800px宽窗口内容区至少500px且底部按钮可用;视口不超过768px时分类栏最小180px,超过断点恢复234px,已保存的宽布局缩至720px仍不溢出。插件管理保留既有安装和启用行为,在真实WebView2及工作区验证。 插件设置在800px宽窗口仍显示详情;拖动插件列表分隔栏检查200px/160px最小宽度、松开前不写入、松开后保存,以及切换页面和重开恢复布局。 Windows逐页切换中英文,核对通用/General、字体/Font、编辑器标签栏/Editor tabs、多行展开/Wrap into rows、Tab宽度/Tab width、Git搁置与Git Stash、AI服务商与API密钥或Token;搜索设置和搜索操作或快捷键的提示及空态与macOS一致,相关命令入口使用通用和Keymap。核对替换前后的本地化键集合和变量占位符一致;关闭行为与原有保存时机保持正确。 Windows当前系统为深色时,先手动选择浅色再切回跟随系统,确认窗口、设置页与编辑器立即恢复深色,配色主题显示实际主题;反向和系统明暗变化也应同步。关闭重开以及重启保持跟随系统;手动模式保持指定外观,重复切换不增加监听。主题元数据为浅色但名称不含light时,模式仍正确显示浅色。 Windows分别在中文和英文界面检索字体连字、显示引用数量和Git作者、LSP自动补全及语义高亮,确认进入正确页面;Sticky Scroll、UI Font Size等当前未提供字段应无结果。在全局搜索持续输入插件关键词时确认焦点不离开搜索框,打开独立扩展管理入口确认插件搜索自动聚焦;Git Fetch配置的prune=true和false均应显示文本值。 Windows在项目设置输入未列出的JDK或Maven路径,候选关闭时按Esc确认路径保留,继续编辑和手动清空仍可用;编辑项目路径或运行参数但不保存,搜索无匹配词后清空查询,确认当前页面草稿仍保留且空态期间控件不可见。 Windows非空路径候选自动化回归:运行settings-path-input.test.tsx,确认方向键只高亮不改草稿、Enter输出所选完整路径、未列出路径可直接输入、打开和关闭弹层时Esc均不清空草稿、关闭后可继续筛选和恢复空字符串自动配置。自动化结果不替代真实WebView2主题切换与连续拖动验收,实机验证仍待完成。 Windows项目及Maven输入未保存路径后搜索Git,再清空查询并切回原页,确认草稿保留且未自动保存;手动切换分类和重复外部导航亦保留。关闭设置后重开,草稿清除且仅挂载当前页,所有已访问页面的订阅在关闭时释放。 Windows打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI聊天与编辑在AI下;从命令面板或其他入口跳转到这些页面、搜索命中时所属分组自动展开且面包屑与侧栏一致,中英文均显示AI;自动化仅为浏览器组件测试,真实WebView2验收待完成。 macOS打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI服务商在AI下;从任意入口跳转到这些页面时所属分组自动展开且面包屑与侧栏一致,搜索命中时展开对应分组。 macOS依次打开通用、Git、Agents 设置页,确认配色主题、外观、背景、语言、项目打开方式、隐藏路径、Git 执行与凭据、Fetch 默认值与配置项、Agent 开关与统计、Agent 说明和供应商说明默认只显示标签和 ⓘ,不再常驻小字;悬停 ⓘ 约半秒显示完整说明,移开或点击外部关闭,点击图标可固定,键盘聚焦后 Space/Return 可打开;错误、警告和 “No remembered choices” 等状态文案仍直接可见。macOS 在 Git 执行页确认 Git 可执行文件为空时仍回显实际路径和版本,输入一个不存在的绝对路径后显示未找到可运行 Git 的警告,点击使用 PATH 中的 Git 后恢复回显;在 Git 行为配置中把“Lithe: 获取标签”设为继承,确认生效值显示具体标签行为(默认获取可达提交的标签),修改某远程的 `remote..tagOpt` 或 `fetch.pruneTags` 后重新加载,确认回显变为不获取、全部获取或同步删除;给远程设置 `skipFetchAll=true` 后确认所有远程模式排除该远程,显式选择该远程时仍显示其标签行为。macOS 在 Git 行为与配置中确认每个选项的标题、当前生效值、下拉框、重置和应用按钮在同一行,短选项的下拉框不再保留大片空白;缩窄设置窗口后当前生效值换到第二行且按钮不被裁切。打开编辑器页切换字体,确认预览使用所选字体和字号,旧字体与新字体短暂并列后淡出;打开插件详情的设置区,确认说明收进 ⓘ 且数值、下拉和复选框仍可编辑。 打开 Git 身份页确认未覆盖时输入框回显当前生效值且保存禁用,编辑后保存、清除覆盖后回到继承值。打开项目 JDK 与 Maven 页确认生效运行时为卡片且路径可复制、长路径可悬停查看全文,settings.xml 和 Local Repository 未覆盖时占位显示有效路径或 Not found。打开 Agents 详情展开预检项,确认版本/路径/安装来源为独立行且长路径中段省略。打开 MCP 配置确认状态卡与配置代码块在启用后出现,关闭后按钮回到 Enable。 Windows 在 125% 与 150% 显示缩放下打开设置对话框,依次切换设置分类(覆盖内容不滚动与需要滚动的页面),确认标题栏、分类栏、正文与底部按钮整体清晰、不出现整层发虚,并在开发者工具中确认弹窗静止态的计算 filter 为 none(入场期间仍应看到模糊淡入)。自动化与静态检查不替代该实机验收。 运行 ContextMenuCoverageTests.newlyInsertedOpenDropdownPresentsAfterAnchorAttaches,覆盖深浅主题下首次更新尚未进入窗口、随后挂载自动显示及移除关闭;设置页实机悬停与点击固定行为另行验收。 macOS 共享 litheSettingsTextEditor 支持传入单一 FocusState 绑定,所有设置多行框使用统一 1pt 普通、2pt 聚焦且外扩 1pt 的控件描边,外框不改变布局尺寸;检查常规隐藏路径、终端环境变量、MCP 和运行配置多行框的聚焦/失焦,未要求自动聚焦的现有页面保留原行为。 设置窗口重新绑定到同一或另一 session 后,OK、Cancel 与标题栏关闭仍能关闭当前窗口;旧 binding 不得关闭新窗口(LitheCoreLogicTests)。 macOS 隐藏路径只有一个模式输入框,下方一行回显内置规则(`**/node_modules/` 等前三项加“+N”胶囊,悬停显示全部),应用按钮在右下;悬停 ⓘ 确认说明写明通配符是正则的简化写法、不区分大小写,并给出 `**/dist/`、`.lithe/*`、`*.log`、`docs/*.md` 四个示例。在框里输入 `.lithe/*` 后点击应用,确认项目树不再显示 `.lithe` 文件夹(只写 `.lithe` 不隐藏文件夹,写 `.lithe/`、`.lithe/*` 或 `.lithe/**` 才隐藏),`.lithe-notes.md` 仍可见;输入 `**/tmp/` 后根目录与任意层级的 `tmp` 文件夹都消失。从旧版本升级时,旧“目录”中的自定义名称迁移成 `**/名称/`、旧“文件模式”条目原样保留,内置项不会复制进输入框。运行 cargo test -p lithe-core folder_patterns_hide_folders_without_reading_them 与 ./scripts/test-macos.sh --filter 'folderPatternsHideWholeFoldersFromTheProjectRoot|hiddenPathsMigrateTheTwoLegacyListsIntoOnePatternList|builtInHiddenPatternsShowTheEffectiveRulesInPatternSyntax',并用 directoryExclusionsFollowTheSharedPruningContract 复核共享 fixture。 隐藏 `.lithe` 后在其中修改一个 JSON 配置(如运行配置),确认项目服务仍然重载;自动化为 hiddenProjectConfigurationFolderStaysWatchedButOtherHiddenPathsAreDropped。" + "notes": "Git 标签继承回显使用 Core 通过 Git 布尔解析器归一化的 pruneTags 值,识别 yes/on/1 及 false 等等价写法;远程设置覆盖全局默认,原始编辑值保持不变。 Windows 当前设置面板的 Editor → Display 提供显示缩略图开关,复用 showMinimap 和 Monaco 实时选项更新。无已保存 showMinimap 配置时默认关闭;已有用户保存的 true 或 false 保留,恢复默认设置也会关闭。macOS 默认值不变。 Windows字体目录使用DirectWrite提供系统及用户字体的规范名、本地化标签与等宽信息;编辑器可选择非等宽字体,目录失败不替换已保存选择,字号6–72与连字设置即时持久化。 Windows设置页通过SettingsSelect统一固定与可搜索下拉框的触发密度和菜单样式,包含运行配置、Git与AI提交;文本、多行文本与复选框复用现有共享控件,空字符串自动配置保持选中态。 macOS设置页把纯解释性说明收进标签后的 ⓘ 提示:默认只显示设置标签和图标,指针停留半秒后显示说明,点击图标可固定,键盘聚焦后用 Space/Return 打开;错误、警告、保存结果、空态和动态状态仍常驻显示。分组级说明复用 LitheSettingsSection 的 accessory,行级说明复用 LitheSettingsLabel 与 LitheInfoTip。 macOS设置行采用标签在左、控件在右的两列布局,分组标题到内容间距 8 pt、分组上方 10 pt、页面级间距 10 pt;编辑器字体页以当前字体的示例代码实时预览,切换字体后旧字体保留约 2.5 秒与新字体上下对比再淡出,减少动态效果开启时取消动画;插件声明的设置摘要同样收进 ⓘ。 macOS Git 行为配置行优先把设置名、当前生效值、下拉框和重置/应用按钮放在同一行,下拉框按该设置最长选项文案在 150–240 pt 内取宽;窗口过窄时当前生效值移到第二行,按钮保持完整。 macOS 项目 JDK 与 Maven 的生效运行时使用卡片展示模式、来源、名称和可选择复制的路径,警告/错误沿用同一卡片形状;Git 身份输入框回显当前生效值,保存按钮仅在草稿变化后启用;Maven 的 settings.xml 与 Local Repository 未覆盖时以实际有效路径作为占位文本,没有时显示 Not found;Agent 预检把版本、路径和安装来源拆成结构化小表,PASS/WARN/FAIL 使用带图标胶囊;MCP 页改为状态卡、项目权限和 Agent 配置分区。 LitheInfoTip 的悬停计时器不再触发页面重新求值,弹层锚点只在说明打开时安装。 项目JDK与Maven路径候选菜单复用SettingsPathInput,保留任意路径直接输入的草稿。 Windows设置对齐macOS分类树、双语检索、面包屑和标题分组线;侧栏默认234px,视口不超过768px时最小180px,否则最小234px;正文最小500px,拖动结束持久化布局。插件页复用既有插件管理且即时操作;普通设置即时保存;底部左侧恢复默认、右侧单个关闭,关闭不回滚已保存修改,项目及运行配置继续显式保存。Windows的AI服务商配置继续由既有AI与AI提交页面负责。 插件设置使用独立可调整的列表/详情分栏,列表最小200px、详情最小160px,窄设置窗口保留详情;原扩展管理入口保留网格布局。 Windows共用设置的中英文文案按macOS设置源码与中文本地化统一,覆盖通用、字体、Code Vision、标签布局、Tab宽度、Git、AI术语、搜索空态与相关命令入口;项目附加和Windows凭据说明保留平台实际语义,底部仍为单个关闭。 Windows跟随系统通过Tauri set_theme(None)解除原生窗口的手动主题覆盖,保留Windows主题事件向WebView2传递系统颜色偏好;复用既有媒体查询同步应用与编辑器,手动模式清理监听。通用页显示实际生效的主题,并按主题元数据判断明暗。 Windows设置检索仅收录当前实际页面字段,始终支持中英文标签;排除旧索引中的不可见控件。插件设置挂载保留全局搜索焦点,独立插件入口继续自动聚焦。Windows设置组件采用settings-panels中性命名;分层、主题、下拉、更新和插件焦点回归已接入Windows CI独立测试进程。 Windows路径输入在候选关闭时按Esc保留已编辑路径,手动清空仍可切回自动配置;搜索无结果时隐藏当前页面并保留未保存草稿,清空搜索恢复显示。 Windows路径候选回归使用非空候选和真实Combobox,覆盖方向键/Enter选择、未列出路径、弹层打开和关闭时Esc保留草稿、再次编辑与清空;候选行使用稳定路径key。 macOS设置侧栏收束为外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制、构建/执行/部署、语言与框架、工具(终端、诊断)、AI(AI服务商、Agent管理、指令、技能、MCP配置、AI与提交,依此排序)七个一级入口,分组关系由SettingsGroup单一来源提供;Windows设置侧栏已同步为同样的七个一级入口:外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制(Git)、构建/执行/部署(项目环境、运行配置、Maven)、语言与框架(LSP)、工具(终端、日志)、AI(MCP配置、AI与提交、AI聊天与编辑);分组由settingsGroups单一来源提供,Windows没有独立AI服务商页和Agents页,不新增空页面,页面内容、存储键和保存时机不变。Windows 设置对话框保留入场模糊淡入,但通过 motion 的 transitionEnd 在入场结束后把插值终值覆盖成 filter: none(overlayEntranceSelfClearing,由 clearEntranceFilter 选择),避免弹窗因为常驻 filter 一直留在滤镜合成层;该层会关闭子像素抗锯齿并按显示缩放比栅格化,让整个弹窗(标题栏、分类栏、正文和底部按钮)一起发虚。该行为只对设置对话框启用,其它弹窗保持原样。 macOS Git 执行页在覆盖为空时回显实际解析到的 Git 路径与版本,并用 `git.executable` 在不打开仓库的情况下完成解析;覆盖路径不可运行时显示警告。Git 行为配置中“Lithe: 获取标签”为继承时,会按 `remote..tagOpt`、`fetch.pruneTags` 与 Lithe 的 prune 选择解析成具体标签传输行为(全部、不获取、同步删除、获取可达提交的标签,或多个远程不一致),不再只显示“遵循 Git 标签规则”。 macOS说明弹层的延迟创建锚点进入窗口后会重试显示,修复悬停或点击仅变蓝而无说明的问题;离开窗口仍取消待显示任务并关闭弹层。 macOS 隐藏路径合并为一个模式列表(原“目录”与“文件模式”两个框),应用按钮在右下,内置规则以只读胶囊回显、始终生效不可移除;语法是不区分大小写的通配符(正则的简化写法),说明与示例收进 ⓘ。文件夹必须用显式模式隐藏:`name/`、`name/*`、`name/**` 都表示从项目根起的文件夹规则,文件夹连同内容一起隐藏并在列目录时剪枝、不读取其内容;开头的 `**/` 代表零层或多层目录,因此 `**/name/` 同时覆盖根目录与任意层级的副本;纯名称和带通配符的文件 glob 仍只匹配文件(规则由 shared/fixtures/search/exclusions.json 固定)。旧版保存的两个列表在升级时合并:自定义目录名变为 `**/名称/`,文件模式原样保留。macOS 把工作区根下的 `.lithe` 作为唯一特例:被规则隐藏后不在项目树、搜索和列目录中显示,但文件监听仍送达其事件,编辑其中的 JSON 仍会重载项目服务(内置噪声如 `.lithe/run/local.json` 仍丢弃)。Windows 隐藏路径沿用既有 gitignore 语义,不在此次范围。", + "verification": "运行 ./scripts/test-macos.sh --filter GitSettingsPresentationTests,并以 cargo test --manifest-path rust/Cargo.toml -p lithe-core git::configuration:: 验证合法布尔写法、无值与空值、远程覆盖、无效值保留和编辑原值不变。 macOS 共享下拉选择器闭合框遵循 Islands:深色底 #26282C、浅色白底,1pt 普通边框、2pt 展开或键盘聚焦描边,28pt 高及 4pt 圆角;验证设置页与 Commit 的深浅外观、键盘聚焦及选择功能。Windows 无已保存缩略图配置时确认代码编辑器右侧不显示缩略图,设置 → Editor → Display 的显示缩略图开关为关闭;切换开关确认所有打开的编辑器即时更新,关闭设置重开及应用重启后选择保持,已有 showMinimap=true/false 的设置均保留,恢复默认后关闭;中英文显示对应文案。修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 Windows 编辑器字号输入数字居中,输入区到独立步进按钮间隔 8px,标准按钮命中范围 32×32px;手动输入、增减、上下限、只读和禁用正确。检查行高、缩进等共用数值控件的布局,并在深浅色设置页验收。 从实际 Windows 设置窗口进入 Editor → Display,确认 Font size 使用共享数值控件,增减与直接输入写入 editor.fontSize,6–72 上下限生效;检查此真实页面的居中、按钮大小及深浅色,避免只验证旧设置页或孤立组件。 步进按钮横排在输入框右侧,左侧增大、右侧减小,各保留 32×32px 点击区域和 4px 间距,输入框与按钮组垂直居中;键盘 Tab 顺序与左右顺序一致。 Windows验证中文与英文目录检索、非等宽字体、目录加载中及失败时的已存选择、共享字号步进控件边界、连字预览以及所有打开编辑器的即时更新。 Windows逐一打开所有设置分类及运行配置编辑器,检查深浅主题的下拉按钮、菜单圆角、边框、选中项与禁用态一致;验证字体和模型搜索、选项键盘选择、Esc仅关闭菜单、点击外部关闭、长列表滚动、标签可访问名称、切换到空字符串自动配置和关闭重开后的持久化;AI提交数值草稿仅在真实失焦或Enter失焦后保存,Maven路径保留显式应用。 项目路径候选菜单检查深浅色、候选选择、任意路径输入后直接点击保存和清空恢复自动配置,确认输入草稿不会被候选列表替换。 Windows逐一访问外观与行为、版本控制、构建/执行/部署、语言与框架、工具、AI六个分组及插件一级页面;中文字段与英文关键词搜索应定位正确页面并展开父组,无结果显示空态,Esc清空查询,外部重复请求同一页清空查询并重新定位。确认第一组标题、水平线、左对齐行、说明和底部左侧恢复默认、右侧只有关闭;中英文分别显示关闭和 Close,点击关闭后即时保存的字号等仍保留,未显式保存的项目草稿丢弃。拖动侧栏时不得连续写入布局,松开后保存,关闭重开或刷新后恢复;720px、768px、769px及800px宽窗口内容区至少500px且底部按钮可用;视口不超过768px时分类栏最小180px,超过断点恢复234px,已保存的宽布局缩至720px仍不溢出。插件管理保留既有安装和启用行为,在真实WebView2及工作区验证。 插件设置在800px宽窗口仍显示详情;拖动插件列表分隔栏检查200px/160px最小宽度、松开前不写入、松开后保存,以及切换页面和重开恢复布局。 Windows逐页切换中英文,核对通用/General、字体/Font、编辑器标签栏/Editor tabs、多行展开/Wrap into rows、Tab宽度/Tab width、Git搁置与Git Stash、AI服务商与API密钥或Token;搜索设置和搜索操作或快捷键的提示及空态与macOS一致,相关命令入口使用通用和Keymap。核对替换前后的本地化键集合和变量占位符一致;关闭行为与原有保存时机保持正确。 Windows当前系统为深色时,先手动选择浅色再切回跟随系统,确认窗口、设置页与编辑器立即恢复深色,配色主题显示实际主题;反向和系统明暗变化也应同步。关闭重开以及重启保持跟随系统;手动模式保持指定外观,重复切换不增加监听。主题元数据为浅色但名称不含light时,模式仍正确显示浅色。 Windows分别在中文和英文界面检索字体连字、显示引用数量和Git作者、LSP自动补全及语义高亮,确认进入正确页面;Sticky Scroll、UI Font Size等当前未提供字段应无结果。在全局搜索持续输入插件关键词时确认焦点不离开搜索框,打开独立扩展管理入口确认插件搜索自动聚焦;Git Fetch配置的prune=true和false均应显示文本值。 Windows在项目设置输入未列出的JDK或Maven路径,候选关闭时按Esc确认路径保留,继续编辑和手动清空仍可用;编辑项目路径或运行参数但不保存,搜索无匹配词后清空查询,确认当前页面草稿仍保留且空态期间控件不可见。 Windows非空路径候选自动化回归:运行settings-path-input.test.tsx,确认方向键只高亮不改草稿、Enter输出所选完整路径、未列出路径可直接输入、打开和关闭弹层时Esc均不清空草稿、关闭后可继续筛选和恢复空字符串自动配置。自动化结果不替代真实WebView2主题切换与连续拖动验收,实机验证仍待完成。 Windows项目及Maven输入未保存路径后搜索Git,再清空查询并切回原页,确认草稿保留且未自动保存;手动切换分类和重复外部导航亦保留。关闭设置后重开,草稿清除且仅挂载当前页,所有已访问页面的订阅在关闭时释放。 Windows打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI聊天与编辑在AI下;从命令面板或其他入口跳转到这些页面、搜索命中时所属分组自动展开且面包屑与侧栏一致,中英文均显示AI;自动化仅为浏览器组件测试,真实WebView2验收待完成。 macOS打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,AI分组内依次为AI服务商、Agent管理、指令、技能、MCP配置、AI与提交;搜索过滤后保留这一显示顺序;从任意入口跳转到这些页面时所属分组自动展开且面包屑与侧栏一致,搜索命中时展开对应分组。 macOS依次打开通用、Git、Agents 设置页,确认配色主题、外观、背景、语言、项目打开方式、隐藏路径、Git 执行与凭据、Fetch 默认值与配置项、Agent 开关与统计、Agent 说明和供应商说明默认只显示标签和 ⓘ,不再常驻小字;悬停 ⓘ 约半秒显示完整说明,移开或点击外部关闭,点击图标可固定,键盘聚焦后 Space/Return 可打开;错误、警告和 “No remembered choices” 等状态文案仍直接可见。macOS 在 Git 执行页确认 Git 可执行文件为空时仍回显实际路径和版本,输入一个不存在的绝对路径后显示未找到可运行 Git 的警告,点击使用 PATH 中的 Git 后恢复回显;在 Git 行为配置中把“Lithe: 获取标签”设为继承,确认生效值显示具体标签行为(默认获取可达提交的标签),修改某远程的 `remote..tagOpt` 或 `fetch.pruneTags` 后重新加载,确认回显变为不获取、全部获取或同步删除;给远程设置 `skipFetchAll=true` 后确认所有远程模式排除该远程,显式选择该远程时仍显示其标签行为。macOS 在 Git 行为与配置中确认每个选项的标题、当前生效值、下拉框、重置和应用按钮在同一行,短选项的下拉框不再保留大片空白;缩窄设置窗口后当前生效值换到第二行且按钮不被裁切。打开编辑器页切换字体,确认预览使用所选字体和字号,旧字体与新字体短暂并列后淡出;打开插件详情的设置区,确认说明收进 ⓘ 且数值、下拉和复选框仍可编辑。 打开 Git 身份页确认未覆盖时输入框回显当前生效值且保存禁用,编辑后保存、清除覆盖后回到继承值。打开项目 JDK 与 Maven 页确认生效运行时为卡片且路径可复制、长路径可悬停查看全文,settings.xml 和 Local Repository 未覆盖时占位显示有效路径或 Not found。打开 Agents 详情展开预检项,确认版本/路径/安装来源为独立行且长路径中段省略。打开 MCP 配置确认状态卡与配置代码块在启用后出现,关闭后按钮回到 Enable。 Windows 在 125% 与 150% 显示缩放下打开设置对话框,依次切换设置分类(覆盖内容不滚动与需要滚动的页面),确认标题栏、分类栏、正文与底部按钮整体清晰、不出现整层发虚,并在开发者工具中确认弹窗静止态的计算 filter 为 none(入场期间仍应看到模糊淡入)。自动化与静态检查不替代该实机验收。 运行 ContextMenuCoverageTests.newlyInsertedOpenDropdownPresentsAfterAnchorAttaches,覆盖深浅主题下首次更新尚未进入窗口、随后挂载自动显示及移除关闭;设置页实机悬停与点击固定行为另行验收。 macOS 共享 litheSettingsTextEditor 支持传入单一 FocusState 绑定,所有设置多行框使用统一 1pt 普通、2pt 聚焦且外扩 1pt 的控件描边,外框不改变布局尺寸;检查常规隐藏路径、终端环境变量、MCP 和运行配置多行框的聚焦/失焦,未要求自动聚焦的现有页面保留原行为。 设置窗口重新绑定到同一或另一 session 后,OK、Cancel 与标题栏关闭仍能关闭当前窗口;旧 binding 不得关闭新窗口(LitheCoreLogicTests)。 macOS 隐藏路径只有一个模式输入框,下方一行回显内置规则(`**/node_modules/` 等前三项加“+N”胶囊,悬停显示全部),应用按钮在右下;悬停 ⓘ 确认说明写明通配符是正则的简化写法、不区分大小写,并给出 `**/dist/`、`.lithe/*`、`*.log`、`docs/*.md` 四个示例。在框里输入 `.lithe/*` 后点击应用,确认项目树不再显示 `.lithe` 文件夹(只写 `.lithe` 不隐藏文件夹,写 `.lithe/`、`.lithe/*` 或 `.lithe/**` 才隐藏),`.lithe-notes.md` 仍可见;输入 `**/tmp/` 后根目录与任意层级的 `tmp` 文件夹都消失。从旧版本升级时,旧“目录”中的自定义名称迁移成 `**/名称/`、旧“文件模式”条目原样保留,内置项不会复制进输入框。运行 cargo test -p lithe-core folder_patterns_hide_folders_without_reading_them 与 ./scripts/test-macos.sh --filter 'folderPatternsHideWholeFoldersFromTheProjectRoot|hiddenPathsMigrateTheTwoLegacyListsIntoOnePatternList|builtInHiddenPatternsShowTheEffectiveRulesInPatternSyntax',并用 directoryExclusionsFollowTheSharedPruningContract 复核共享 fixture。 隐藏 `.lithe` 后在其中修改一个 JSON 配置(如运行配置),确认项目服务仍然重载;自动化为 hiddenProjectConfigurationFolderStaysWatchedButOtherHiddenPathsAreDropped。" } From ee5081e7cd4edea7e3f823aa2244d709ea3e746d Mon Sep 17 00:00:00 2001 From: Lichenkang <2188718831@qq.com> Date: Fri, 23 Oct 2026 09:42:35 -0700 Subject: [PATCH 12/50] =?UTF-8?q?fix(ci):=20=E6=94=BE=E5=AE=BD=20macOS=20S?= =?UTF-8?q?wift=20=E7=BC=96=E8=AF=91=E9=A2=84=E7=AE=97=E5=B9=B6=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=9E=84=E5=BB=BA=E5=A4=B1=E8=B4=A5=E5=8F=A0=E5=8A=A0?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=A4=B1=E8=B4=A5=20(#1449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swift-tests 的冷编译在运行 39943871840 中超过 20 分钟上限,与插件通道 39741361768 同类;编译步骤放宽到 40 分钟(job 80 分钟),测试执行预算不变。构建失败时不再叠加合并报告失败,避免同一失败被重复计数。 --- .../2026-09-13-ci-build-cache-and-artifact-strategy.md | 7 +++++++ .github/workflows/ci-macos.yml | 10 ++++++++-- docs/ci-builds.md | 6 ++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index 9f0f038c39..f1981b061b 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -148,6 +148,13 @@ Agent Host 及 ACP/MCP 协议依赖时,触发了原先的两分钟总限额, 没有提高测试执行预算,也不把源任务失败或未生成测试报告当成通过。代价是异常 冷编译最多占用更久的 runner;不能用整个 job 的超时替代单测试的有界等待。 +Swift 测试通道使用同样的分离方式。运行 `39943871840` 中,`scripts/test-macos.sh list` +在 20 分钟上限到期时仍停在 1406/1456 个编译任务;被复用的失败任务又使 macOS 门禁 +失败。`swift-tests` 的编译步骤现为 40 分钟、整个 job 80 分钟,与插件通道一致; +单元、插件和数据库测试仍使用 960 秒执行预算和 17 分钟外层步骤。构建失败时跳过合并 +报告生成,避免把同一个构建失败重复计成“未生成测试报告”;构建成功后报告缺失仍然 +使该步骤失败,不能用跳过报告掩盖测试执行问题。 + 此前在旧 runner 上通过 Swiftly 安装独立工具链失败,测试未能启动。因此继续 使用完整 Xcode,让 Swift、链接器和 SDK 保持匹配。Xcode 27/SDK 27 只作为 开发者本机的兼容路径,不进入 CI 基线;`MacOS13SDKCompatibility.h` 仅在 diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 717f51c1e4..f42ab1a706 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -271,8 +271,12 @@ jobs: cargo: "true" # Compile and discover tests before starting the test execution deadline. + # A cold Rust/Swift build still compiling Lithe exceeded 20 minutes in + # run 39943871840, matching the plugin lane's 39741361768. Keep this hang + # guard separate from test execution; test steps keep their own budgets. - name: Build Swift tests - timeout-minutes: 20 + id: build + timeout-minutes: 40 run: ./scripts/test-macos.sh list > /dev/null - name: Verify the Rust Core Swift bridge @@ -342,8 +346,10 @@ jobs: timeout-minutes: 17 run: ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --report .artifacts/test-stability/macos-database.json --suite-timeout-seconds 960 -- --skip-build --filter '(LitheDatabaseModuleTests|LitheTests\.LitheCoreLogicTests/database)' + # A failed build cannot produce reports; the build step already fails the + # job, so do not stack a second "no reports found" failure on top of it. - name: Generate combined Swift test report - if: always() + if: always() && steps.build.outcome == 'success' run: node .agents/skills/write-stable-tests/scripts/generate-test-report.mjs - name: Upload Swift test reports diff --git a/docs/ci-builds.md b/docs/ci-builds.md index c11bc8743c..e4c4a4a660 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -351,9 +351,11 @@ Swift 文本文件策略直接调用 Rust Core,因此 `scripts/test-macos.sh` 当前工作树的 Core 静态库。它复用现有 `rust/target/macos` 构建路径;这是可变编译状态, 不允许跨工作树复制或共享,也没有新增可复用资源目录。依赖下载仍按下文清单复用。 Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存。先通过 -`./scripts/test-macos.sh list` 构建并发现测试,独立步骤上限为 20 分钟;随后计时工具 +`./scripts/test-macos.sh list` 构建并发现测试,独立步骤上限为 40 分钟;随后计时工具 传入 `--skip-build`,测试阶段保留 960 秒总时限及 17 分钟外层步骤时限,给超时清理 -与报告留出一分钟。冷构建不再消耗测试执行预算;单测试预算和无输出看门狗保持不变。 +与报告留出一分钟。构建失败时不生成合并报告,避免同一失败被重复计为“未生成测试 +报告”;构建成功而报告缺失仍会失败。冷构建不再消耗测试执行预算;单测试预算和无 +输出看门狗保持不变。 ### 独立工作树的本地编译 From c5d0907194231867ff022da62f041f5743238e75 Mon Sep 17 00:00:00 2001 From: puppyben1 <136492871+puppyben1@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:37:04 +0800 Subject: [PATCH 13/50] =?UTF-8?q?feat(windows):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=A0=8F=E6=8B=96=E5=8A=A8=E5=81=9C=E9=9D=A0?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E6=AD=A3=E6=90=9C=E7=B4=A2=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=20(#1436)=20(#1446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(windows): dock terminal panel at bottom or right (#1436) * fix(windows): drag terminal activity entry to either side * fix(terminal): leave project search shortcut to workbench * fix(terminal): focus docked sessions after the pane becomes visible --- .../2026-10-12-tool-window-docking.md | 37 +- .../features/terminal-interaction.json | 6 +- .../workbench-tool-window-docking.json | 11 +- .../bottom-pane-terminal-lifecycle.test.tsx | 453 +++++++++++++++++- .../components/bottom-pane/bottom-pane.tsx | 139 +++--- .../layout/components/main-layout.tsx | 71 ++- .../components/plugin-activity-rail.tsx | 43 +- .../sidebar/sidebar-pane-selector.tsx | 24 +- .../components/terminal-dock-layout.tsx | 369 ++++++++++++++ .../settings/config/default-settings.ts | 3 + .../lib/settings-normalization.test.ts | 28 ++ .../settings/lib/settings-normalization.ts | 29 +- .../features/settings/types/settings.types.ts | 3 + .../terminal/components/terminal-tab-bar.tsx | 3 + .../features/terminal/components/terminal.tsx | 10 +- .../terminal/utils/terminal-keyboard.test.ts | 21 +- .../terminal/utils/terminal-keyboard.ts | 12 + windows/tauri/src/i18n/locale.ts | 10 + 18 files changed, 1141 insertions(+), 131 deletions(-) create mode 100644 windows/tauri/src/features/layout/components/terminal-dock-layout.tsx diff --git a/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md b/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md index 121eab8561..1ce93c11a3 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md +++ b/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md @@ -73,7 +73,7 @@ macOS 的工具窗口位置由工作台维护,功能模块继续维护自己 ## 后果 收益是现有内容可以在不同区域显示,位置、顺序与尺寸可以重启恢复。 -代价是新增内置工具窗口必须提供稳定标识和默认布局;Windows 尚未同步此次移动能力,功能矩阵明确记录差距。 +代价是新增内置工具窗口必须提供稳定标识和默认布局;Windows 目前只支持终端底部与右侧互移,完整分组和排序的差距由功能矩阵记录。 移动可能重建原生呈现容器,但功能状态仍归原模块所有;终端与编辑器等原生资源必须继续遵守既有挂载生命周期。 ## 验证 @@ -94,6 +94,41 @@ macOS 的工具窗口位置由工作台维护,功能模块继续维护自己 - `macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift` - `macos/Sources/Lithe/Views/Workbench/` - `macos/Tests/LitheTests/ToolWindowDockingTests.swift` +- `windows/tauri/src/features/layout/components/terminal-dock-layout.tsx` +- `windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx` + +## Windows 终端入口拖动与左、右、底部停靠 + +Windows 可直接拖动活动栏的终端图标,停靠到左侧、右侧或底部;入口随位置移动到对应侧栏, +底部使用左侧入口。终端隐藏或其他工具活动时也能开始拖动,只有有效释放才打开终端, +普通点击或键盘激活仍切换显隐。两侧活动栏与内容布局处于同一拖动上下文,侧栏本身也是落点, +因此在右下角释放会选择右侧,而不会误判为底部。终端工具栏的移动按钮保留为补充入口。 +活动栏有效释放后先提交面板显隐和活动工具,React 完成 DOM 更新后才请求创建或聚焦终端; +不能在同一个 pointerup 处理器里对仍隐藏的 xterm 同步 focus。待激活请求绑定当前工作区, +提交期间切换项目或工具会丢弃请求,不使用固定延时,也不重新挂载终端。 +Esc、窗口失焦、窗口大小变化、无效区域释放、 +切换项目及卸载都会取消拖动,不保存位置。落点按拖动开始时的工作区和面板实际边界计算, +底部角落优先保留来源边,避免底部水平拖动突然跳到右侧。 + +布局使用 CSS Grid(网格布局)的区域名移动同一份 BottomPane,不能把它分别放到两个条件分支。 +例如正确做法是只把 panel 区域从底行改到左列或右列;不要销毁 TerminalContainer 再在侧边重建它, +否则会重复注册创建终端的监听器并丢失局部分屏状态。改变底部全宽/编辑区宽度、停靠、 +隐藏和再次展开都保留容器;PTY(终端进程连接)和 xterm 输出仍由原来的 TerminalHost 管理。 +原有终端标签页的拖动继续用于排序或送入编辑器,与整个面板的移动按钮分开。 + +位置、侧边宽度和底部高度使用现有 Windows 设置存储保存,作用于当前应用设置, +并非 macOS 的按工作区六组布局。旧设置默认底部、400px 宽、320px 高,未知位置安全回到底部; +尺寸在加载和使用时分别校验,并按剩余工作区限制。持续调整复用 document-resize-session, +每帧只改局部 DOM,结束才保存;切换位置/项目及卸载清理监听器。左侧分隔条放在面板右边缘, +向右拖动增加宽度;保持同一 DOM 子节点顺序,通过 CSS 排序移动分隔条,防止终端因换位而重建。 +其他底部工具仍使用原来的单活动工具语义,不支持和终端同时展开,也不随终端移到侧边。 +本次没有新增运行时资源目录或原生协议。 + +验证:Windows Frontend 计时 harness 运行 +`windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx`, +覆盖真实左右活动栏入口与 BottomPane 的三边停靠、预览先于保存、取消/迟到释放、宽度边界、 +全宽切换、隐藏再打开、输入 DOM 和请求监听器保留;设置归一化测试覆盖旧数据和损坏数据恢复。 +这些组件测试不等同于 Windows WebView2 实机验收,矩阵保持 pending。 拖动回归补充:`ToolWindowDockingTests.horizontalDragUsesTheBottomPanelHeightAndActualSplitBounds` 覆盖底部平移、编辑区排除、遮罩保持实际高度及不等宽双组; diff --git a/shared/platform-feature-matrix/features/terminal-interaction.json b/shared/platform-feature-matrix/features/terminal-interaction.json index 02c11bfe2c..e54052f1a7 100644 --- a/shared/platform-feature-matrix/features/terminal-interaction.json +++ b/shared/platform-feature-matrix/features/terminal-interaction.json @@ -22,11 +22,13 @@ "evidence": [ "windows/tauri/src/features/terminal", "windows/tauri/src/features/terminal/hooks/use-terminal-addons.ts", - ".agents/notes/implemented/simplification/2026-10-08-windows-terminal-direct-web-links.md" + ".agents/notes/implemented/simplification/2026-10-08-windows-terminal-direct-web-links.md", + "windows/tauri/src/features/terminal/utils/terminal-keyboard.ts", + "windows/tauri/src/features/terminal/utils/terminal-keyboard.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,纯 Control 组合键(Ctrl-D 结束输入、Ctrl-R 历史搜索、Ctrl-W 删词)原样交回终端写入 PTY,不再被应用键位的 Debug/Run 截获;带 Command、Shift 或 Option 的组合键仍走应用键位,Ctrl+Shift+F 等项目级命令不受影响(TerminalShortcutInputPolicyTests)。" + "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,纯 Control 组合键(Ctrl-D 结束输入、Ctrl-R 历史搜索、Ctrl-W 删词)原样交回终端写入 PTY,不再被应用键位的 Debug/Run 截获;带 Command、Shift 或 Option 的组合键仍走应用键位,Ctrl+Shift+F 等项目级命令不受影响(TerminalShortcutInputPolicyTests)。 Windows 聚焦终端或终端搜索框后按 Ctrl+Shift+F,须打开项目搜索而不弹出终端内搜索;Ctrl+F 仍搜索当前终端输出,Ctrl+Alt+F/AltGr 输入及已被其他命令处理的按键不能误开终端搜索。使用 terminal-keyboard.test.ts 验证快捷键边界,WebView2 实机验收保持 pending。" } diff --git a/shared/platform-feature-matrix/features/workbench-tool-window-docking.json b/shared/platform-feature-matrix/features/workbench-tool-window-docking.json index 83f2ca930f..fbf80078ee 100644 --- a/shared/platform-feature-matrix/features/workbench-tool-window-docking.json +++ b/shared/platform-feature-matrix/features/workbench-tool-window-docking.json @@ -17,11 +17,16 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/layout" + "windows/tauri/src/features/layout/components/terminal-dock-layout.tsx", + "windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx", + "windows/tauri/src/features/layout/components/plugin-activity-rail.tsx", + "windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx", + "windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx", + "windows/tauri/src/features/settings/lib/settings-normalization.test.ts" ], - "implementationStatus": "missing", + "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Workbench", - "verification": "macOS 将 Project、Database、Terminal、Git Log、Maven 和 Agent 入口拖到左/右主组、第二组及底部左右组,组内拖动排序;核对同组互斥、跨组同时打开、原动作和模块会话保留、标题收起只关闭对应窗口、右键移动和键盘可用性。取消与窗口外释放保持原位置,切换工作区和重启恢复位置/顺序/显隐/尺寸及组内比例,旧布局迁移、损坏与重复标识安全恢复。深浅主题核对 IDEA 资源和落点 token,窄窗口连续调整尺寸不产生负值且仅结束写入设置。Windows 尚未同步工具窗口停靠拖放,现有编辑器分屏不算此能力。 激活入口的浮动拖动预览显示普通图标和 IDEA 灰色圆角背景/同色 1pt 边框,边角透明,不带源按钮激活或 hover 背景;活动栏自身仍保留正常 hover 提示。 左、右及底部双分组各有独立背景和圆角,组间是工作台间距;核对 Project 与 Terminal 相邻时不连成一个面板,调整比例后仍保留终端输入和其他面板。 分隔器松开到父布局回传期间保持最终尺寸,不回跳;低于10%及高于90%的合法窄面板比例保存后和重启后不被二次夹紧。 将 9 个工具入口放入 280pt 滚动视口,滚动后点击或拖动邻组按钮,验证隐藏入口不截获事件、不进入落点快照;部分可见入口只在可见区域接受按下与释放,隐藏祖先也不能响应。 拖动源不留空槽,相邻按钮即时补位且浮动图标和鼠标生命周期不中断;蓝色插入槽内目标稳定,补位后的按钮使用实时可见边界命中。底部入口平移进入当前底部面板时保持 Bottom Left/Right,进入上方侧面区域才切换侧面停靠;遮罩使用当前面板真实高度,双组使用真实比例而非固定二分,调整面板大小后重新拖动仍一致。侧面空槽只沿编辑区高度预测,未打开底部时按 Community 200pt/四分之一默认值;取消恢复源入口,不修改持久布局。 将组内第二个按钮保持 Y 不变水平拖入内容区,蓝色占位必须保留中间位置,不能跳到列表末尾;上下经过按钮中点后顺序才改变,真正移到所有按钮下方才追加。直接经过侧栏和移入内容区使用相同主轴投影判定。 主工具栏不显示 Workbench Actions 省略号及其 tooltip;验证现有工具窗口和工具栏入口的右键管理动作、注册贡献执行及偏好存储仍正常,不新增替代菜单入口。 在无背景图且目标区域已显示 Git Log 或终端时,拖入底部左右目标,落点高亮和位置文字应显示在不透明内容上方;明暗主题及带偏移的工作区保持正确坐标,提示不截获鼠标,取消后消失且保留内容宿主不重建(ToolWindowDockingTests.dropPreviewStaysAboveRetainedPane)。" + "verification": "macOS 将 Project、Database、Terminal、Git Log、Maven 和 Agent 入口拖到左/右主组、第二组及底部左右组,组内拖动排序;核对同组互斥、跨组同时打开、原动作和模块会话保留、标题收起只关闭对应窗口、右键移动和键盘可用性。取消与窗口外释放保持原位置,切换工作区和重启恢复位置/顺序/显隐/尺寸及组内比例,旧布局迁移、损坏与重复标识安全恢复。深浅主题核对 IDEA 资源和落点 token,窄窗口连续调整尺寸不产生负值且仅结束写入设置。Windows 已实现直接拖动活动栏终端图标在左侧、右侧及底部停靠,入口随位置移到对应侧栏,普通点击及键盘激活切换显隐;终端工具栏移动按钮保留,位置及尺寸随应用设置恢复;完整工具分组、排序与各工作区布局仍缺失。实机检查从左下角终端入口拖到左右两侧及底部(包括两侧栏的下角),隐藏时拖动须等有效释放才打开,已有会话需在显示提交后获得输入焦点(取消仍保持编辑器焦点),取消须保留原工具和显隐,落点遮罩与释放位置一致,左侧分隔条向右增加宽度、Esc/失焦/窗口外/切换项目取消、不重复触发点击、窄窗口调整宽高、隐藏后再次展开和重启恢复;确认 PTY、输出、输入、工作目录、终端标签排序及编辑器拖入不变。组件回归保持同一 TerminalContainer 和输入 DOM,不能替代 WebView2 验收。 激活入口的浮动拖动预览显示普通图标和 IDEA 灰色圆角背景/同色 1pt 边框,边角透明,不带源按钮激活或 hover 背景;活动栏自身仍保留正常 hover 提示。 左、右及底部双分组各有独立背景和圆角,组间是工作台间距;核对 Project 与 Terminal 相邻时不连成一个面板,调整比例后仍保留终端输入和其他面板。 分隔器松开到父布局回传期间保持最终尺寸,不回跳;低于10%及高于90%的合法窄面板比例保存后和重启后不被二次夹紧。 将 9 个工具入口放入 280pt 滚动视口,滚动后点击或拖动邻组按钮,验证隐藏入口不截获事件、不进入落点快照;部分可见入口只在可见区域接受按下与释放,隐藏祖先也不能响应。 拖动源不留空槽,相邻按钮即时补位且浮动图标和鼠标生命周期不中断;蓝色插入槽内目标稳定,补位后的按钮使用实时可见边界命中。底部入口平移进入当前底部面板时保持 Bottom Left/Right,进入上方侧面区域才切换侧面停靠;遮罩使用当前面板真实高度,双组使用真实比例而非固定二分,调整面板大小后重新拖动仍一致。侧面空槽只沿编辑区高度预测,未打开底部时按 Community 200pt/四分之一默认值;取消恢复源入口,不修改持久布局。 将组内第二个按钮保持 Y 不变水平拖入内容区,蓝色占位必须保留中间位置,不能跳到列表末尾;上下经过按钮中点后顺序才改变,真正移到所有按钮下方才追加。直接经过侧栏和移入内容区使用相同主轴投影判定。 主工具栏不显示 Workbench Actions 省略号及其 tooltip;验证现有工具窗口和工具栏入口的右键管理动作、注册贡献执行及偏好存储仍正常,不新增替代菜单入口。 在无背景图且目标区域已显示 Git Log 或终端时,拖入底部左右目标,落点高亮和位置文字应显示在不透明内容上方;明暗主题及带偏移的工作区保持正确坐标,提示不截获鼠标,取消后消失且保留内容宿主不重建(ToolWindowDockingTests.dropPreviewStaysAboveRetainedPane)。" } diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx index aba9823f16..71a7344674 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx @@ -1,5 +1,5 @@ import { afterAll, expect, mock, test } from "bun:test"; -import { act, useEffect } from "react"; +import { act, useEffect, useRef } from "react"; import { create } from "zustand"; import { installHappyDom } from "@/test-utils/happy-dom"; import { @@ -7,6 +7,7 @@ import { requestNewTerminal, } from "@/features/terminal/utils/terminal-launch-actions"; import { openNewTerminalFromGlobalEntry } from "@/features/terminal/utils/terminal-pane-visibility"; +import { defaultSettings } from "@/features/settings/config/default-settings"; const restoreDom = installHappyDom(); afterAll(restoreDom); @@ -26,10 +27,36 @@ const ui = create<{ let mounts = 0; let unmounts = 0; const launches: Array = []; +const writes: Array<[string, unknown]> = []; +const settings = create(() => ({ + settings: { + coreFeatures: { + ...defaultSettings.coreFeatures, + terminal: true, + debugger: false, + git: false, + search: false, + diagnostics: false, + }, + sidebarActivityItemsOrder: [] as string[], + hiddenSidebarActivityItems: [] as string[], + terminalDockPosition: "bottom" as "bottom" | "left" | "right", + terminalDockWidth: 400, + bottomPaneHeight: 320, + }, + actions: { + updateSetting: (key: string, value: unknown) => { + writes.push([key, value]); + settings.setState((state) => ({ settings: { ...state.settings, [key]: value } })); + }, + }, +})); +const terminal = create(() => ({ widthMode: "full" as "full" | "editor" })); // Keep the real BottomPane and fullscreen surface. The terminal leaf replaces // xterm/native PTYs but owns the same production event subscription boundary. function TerminalProbe({ onFullScreen }: { onFullScreen: () => void }) { + const inputRef = useRef(null); useEffect(() => { mounts++; const handler = createNewTerminalEventHandler((directory) => { @@ -40,12 +67,25 @@ function TerminalProbe({ onFullScreen }: { onFullScreen: () => void }) { }); }); window.addEventListener("terminal-new", handler); + // Emulate xterm's focus boundary: a mounted but hidden input cannot focus. + const ensure = () => { + const input = inputRef.current; + if (input && !input.closest(".hidden, [hidden]")) input.focus(); + }; + window.addEventListener("terminal-ensure-session", ensure); return () => { unmounts++; window.removeEventListener("terminal-new", handler); + window.removeEventListener("terminal-ensure-session", ensure); }; }, []); - return ; + return ( + <> + + + + + ); } mock.module("@/features/terminal/components/terminal-container", () => ({ @@ -57,16 +97,37 @@ mock.module("@/features/window/stores/project.store", () => ({ select({ rootFolderPath: "C:/fixture/project" }), })); mock.module("@/features/settings/stores/settings.store", () => ({ - useSettingsStore: ( - select: (state: { - settings: { coreFeatures: { terminal: boolean; debugger: boolean } }; - }) => unknown, - ) => select({ settings: { coreFeatures: { terminal: true, debugger: false } } }), + useSettingsStore: settings, +})); +mock.module("@/features/terminal/stores/terminal.store", () => ({ useTerminalStore: terminal })); +mock.module("@/features/keymaps/hooks/use-command-shortcut", () => ({ + useCommandShortcut: () => undefined, })); mock.module("@/features/editor/stores/buffer.store", () => ({ - useBufferStore: { - use: { actions: () => ({ moveBufferToPane: () => {}, openTerminalBuffer: () => "" }) }, - }, + useBufferStore: Object.assign( + create(() => ({ activeBufferId: null, buffers: [] })), + { + use: { actions: () => ({ moveBufferToPane: () => {}, openTerminalBuffer: () => "" }) }, + }, + ), +})); +const toggleTerminal = () => { + const state = ui.getState(); + const wasActive = state.isBottomPaneVisible && state.bottomPaneActiveTab === "terminal"; + state.setBottomPaneActiveTab("terminal"); + state.setIsBottomPaneVisible(!wasActive); +}; +mock.module("@/features/keymaps/commands/view-command-actions", () => ({ + toggleMavenPane: () => {}, + toggleTerminalPane: toggleTerminal, +})); +mock.module("@/features/maven/stores/maven.store", () => ({ + useMavenStore: (select: (state: unknown) => unknown) => + select({ project: null, projectStatus: "idle" }), +})); +mock.module("@/features/agent/components/agent-trigger", () => ({ AgentTrigger: () => null })); +mock.module("@/features/notifications/components/notifications-trigger", () => ({ + NotificationsTrigger: () => null, })); mock.module("@/features/panes/stores/pane.store", () => ({ usePaneStore: { @@ -78,6 +139,7 @@ mock.module("@/features/panes/utils/pane-activation", () => ({ activateBufferInPaneAndSync: () => {}, })); mock.module("@/config/backend-capabilities", () => ({ + BACKEND_UNAVAILABLE_TOOLTIP: "Unavailable in this fixture", isBackendCapabilityAvailable: (name: string) => name === "terminal", })); mock.module("@/i18n/locale-provider", () => ({ @@ -100,6 +162,377 @@ mock.module("@/features/tabs/utils/internal-tab-drag", () => ({ getInternalTabDragHover: () => ({ paneId: null }), })); const { default: BottomPane } = await import("./bottom-pane"); +const { TerminalDockLayout, TerminalDockHandle } = await import("../terminal-dock-layout"); +const { SidebarPaneSelector } = await import("../sidebar/sidebar-pane-selector"); +const { PluginActivityRail } = await import("../plugin-activity-rail"); + +async function dockFixture() { + ui.setState({ isBottomPaneVisible: true, bottomPaneActiveTab: "terminal" }); + settings.setState((state) => ({ + settings: { + ...state.settings, + terminalDockPosition: "bottom", + terminalDockWidth: 400, + bottomPaneHeight: 320, + hiddenSidebarActivityItems: [], + }, + })); + terminal.setState({ widthMode: "full" }); + writes.length = 0; + launches.length = 0; + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + const dimensionDescriptors = ["clientWidth", "clientHeight"].map( + (key) => [key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key)] as const, + ); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => 1000, + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get: () => 700, + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = async (workspaceKey = "project-a") => + act(async () => + root.render( + {}} + onTerminalClick={toggleTerminal} + orientation="vertical" + compact + /> + } + rightRail={} + sidebar={Project} + tools={Agent} + editor={