diff --git a/rust/Cargo.lock b/rust/Cargo.lock index aa0063a1..0be4812d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -907,6 +907,7 @@ dependencies = [ "lithe-diff", "lithe-document-uri", "lithe-editor", + "lithe-editor-layout", "lithe-execution", "lithe-git", "lithe-git-host", @@ -921,6 +922,7 @@ dependencies = [ "lithe-plugin-function-relay", "lithe-plugin-keymap-import", "lithe-plugin-language-servers", + "lithe-plugin-lifecycle", "lithe-plugin-manifest", "lithe-plugin-marketplace", "lithe-plugin-model", @@ -931,9 +933,11 @@ dependencies = [ "lithe-process", "lithe-project", "lithe-project-generators", + "lithe-project-tree", "lithe-protocol", "lithe-remote", "lithe-request-context", + "lithe-row-selection", "lithe-terminal", "lithe-text", "lithe-toolchain", @@ -1010,6 +1014,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lithe-editor-layout" +version = "0.1.0" +dependencies = [ + "lithe-core-rules", + "lithe-protocol", + "serde", + "serde_json", +] + [[package]] name = "lithe-execution" version = "0.1.0" @@ -1236,6 +1250,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lithe-plugin-lifecycle" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "serde", + "serde_json", +] + [[package]] name = "lithe-plugin-manifest" version = "0.1.0" @@ -1382,6 +1405,16 @@ dependencies = [ "zip", ] +[[package]] +name = "lithe-project-tree" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "lithe-row-selection", + "serde", + "serde_json", +] + [[package]] name = "lithe-protocol" version = "0.1.0" @@ -1412,6 +1445,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lithe-row-selection" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "serde", + "serde_json", +] + [[package]] name = "lithe-terminal" version = "0.1.0" diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 000fe347..b70ae6d4 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -178,11 +178,51 @@ returned as sent, so a host may send only the groups an action involves. `emptyG `normalize.unplaced` members no group lists join the focused group (the first group when `focusedGroup` is missing or stale), and `focusedGroup` moves to the first remaining group when its group was removed. Actions that name an unknown group or an unknown move destination change nothing. `removedGroupIds` lists removed groups in their previous order so -hosts prune their own layout tree; split directions, ratios, pixels and dragging never reach Core. A malformed payload is -`invalid_request`. It lives in `rust/crates/core-rules` and hosts call it synchronously through the C ABI, one call per -user action. Core and the rules crate run every case of +hosts that keep their own layout tree can prune it; a host that keeps the tree in the shape below uses +`editor.groupLayout.reduce` instead. A malformed payload is `invalid_request`. It lives in `rust/crates/core-rules` and +hosts call it synchronously through the C ABI, one call per user action. Core and the rules crate run every case of `rust/fixtures/editor/workbench-state-v1.json` through it. +`editor.groupLayout.reduce` accepts `{state?, layout?, action}` and returns +`{state, layout, removedGroupIds, group?, rejection?}`. `state` is the `editor.workbenchState.reduce` state, and its +group IDs must be unique and not empty. `layout` is the tree that divides the editor area among the groups: a leaf +`{group}` or a split `{node, row?, first, second}`, where `row` lays the children side by side (otherwise `first` is +above `second`) and `node` is the host's number for the split, unique in the tree; a group appears at most once, and +`layout` is `null` or missing when the editor area shows no group. Groups the layout does not show, such as a group +docked in a tool window, may be in `state`. Splitter ratios, pixels and the drag in progress stay in the host, which +keeps them by `node` and starts a split Core made in the middle. Before and after the action a leaf whose group is not +in `state` leaves the tree, and the sibling of a removed leaf takes the room of their split. `action` is any action of +`editor.workbenchState.reduce`, or tagged by `kind`: + +- `openTab {member, preferredGroup?, newGroup}` opens and shows `member` in `preferredGroup` while the layout shows it, + otherwise in the first group of the layout (leftmost, then topmost). Without a layout it adds the empty group + `newGroup`, which must be new, after the others and makes it the whole layout. +- `dropTab {member, target, docked?, dockable?, newGroup?, newNode?}` releases a dragged tab. `docked` lists groups in + tool windows and `dockable` says whether the tab may join them. `target` is tagged by `kind`: `join {group, index}` + puts the tab in `group` at `index` counted among its tabs without the dragged one (an index past the end appends; in + the tab's own group it reorders), `split {group, edge}` with `edge` `left`, `right`, `top` or `bottom` puts it in the + new group `newGroup` on that side of `group` under the new split `newNode`, and `empty` puts it in the new group + `newGroup` that becomes the whole layout. The tab is shown where it lands, and a group it left empty is removed + unless it is docked. `group` in the response names that group. A drop whose tab or place no longer fits the request + returns `rejection: "editor.groupLayout.stale"` (the tab is in no group, `group` is not in `state`, a split `group` + is not in `layout`, or `empty` while `layout` shows a group); a place the drop rules refuse returns + `rejection: "editor.groupLayout.refused"`. With a rejection, `state` and `layout` are as sent. A split or an empty + drop without a new `newGroup`, or a split without a `newNode` unused in the layout, is `invalid_request`. +- `removeEmptyGroups {groups}` removes the named groups that have no tab and keeps every other group. + +`group` is set after `openTab` and an applied `dropTab`. `removedGroupIds` lists the groups that left `state` in their +previous order. + +`editor.groupLayout.dropTargets` accepts `{state?, layout?, member, docked?, dockable?}` and returns +`{groups: [{group, join, split, append}], empty}`, one entry per group of `state` in its order. The drop rules: a group +whose only tab is `member` accepts nothing, since the drop changes nothing, and neither does a docked group when the tab +is not `dockable`. Every other group accepts the tab in its strip (`join`); its body accepts a split on an edge +(`split`) unless the group is docked or the layout does not show it, and accepts the tab at its end (`append`) unless +the tab is already in the group. `empty` says the editor area shows no group and the tab may open its first one. A +tab no group has gets no place. The host asks once when a drag starts and hit-tests the pointer against what it +painted. A malformed payload is `invalid_request` for both commands. Both live in `rust/crates/editor-layout`; Core +runs every case of `rust/fixtures/editor/group-layout-v1.json` through them. + `workspace.classifyVisibility` accepts `{hiddenDirectoryNames?, hiddenFilePatterns?, entries: [{path, isDirectory?}]}` and returns `{hidden: boolean[], watchedDespiteHiding: boolean[]}` with one answer per entry, in request order. `path` is workspace-relative with `/` separators; `isDirectory` is `true` for a folder, `false` for a file and omitted when the @@ -389,6 +429,7 @@ or removing a command fails until the table changes in the same commit. | `plugins.declarative.settingsState` | Read complete plugin preference documents into Core-normalized rows, sources, and bounded native read plans | | `plugins.declarative.settingsActionPlan` | Resolve an installed settings action and its native confirmation requirement | | `plugins.installed.select` | Select numeric installed versions from native facts without reading paths | +| `plugins.installed.lifecycle` | Report installed plugins' enablement and plan confirming an install, enabling, disabling or uninstalling one | | `plugins.declarative.editSettings` | Validate one atomic setting edit batch and return replacement text while preserving unrelated plugins and dormant values | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.decodeGrammar` | Validate independent static editor resources | @@ -466,6 +507,8 @@ or removing a command fails until the table changes in the same commit. | `workspace.filterChanges` | Filter an array of workspace-relative paths, or `{ paths, hiddenFilePatterns }` whose `!` entries turn default exclusions off, against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files | | `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `rust/fixtures/workspace/file-name-order-v1.json`) | | `workspace.orderEntries` | Order natively enumerated directory siblings | +| `workspace.projectTree.rows` | Say which loaded project tree nodes show as rows, keep the selection on a row that shows, and find the row the top of the view was on, without reading the disk (from `lithe-project-tree`) | +| `workspace.rowSelection.reduce` | Apply one plain, Cmd/Ctrl or Shift click, or the end of a batch, to the multi-selection of rows of workspace paths and return what a batch acts on (from `lithe-row-selection`) | | `workspace.rankNames` | Pure ranking of candidate names that the platform enumerates itself (`{ query, candidates: [{ name, path? }], context: { openPaths, recentPaths }, limitPerTier? }`). Use it only for lists Core cannot index: Windows remote/WSL roots, symbol lists, navigator lists. Roots Core indexes go through `workspace.searchEverywhere`, so an inventory is never sent back to Core. Returns `matches: [{ index, degree, tier, nameMatchRanges }]` ordered by degree, open/recent tier, recency, then file-name order (fixture `rust/fixtures/search/rank-names-v1.json`) | | `workspace.planDuplicate` | Name the copy of one project item next to its source (`foo.txt` → `foo copy.txt`, then `foo copy 2.txt`) from the sibling names the host has seen to exist | | `workspace.planPaste` | Name pasted project items in one destination directory: a free name is kept, a taken one gets the copy suffix, and earlier items of the same paste count as taken | @@ -512,6 +555,8 @@ or removing a command fails until the table changes in the same commit. | `keymap.analyzeConflicts` | Find conflicts among the final layered shortcut bindings (from `lithe-core-rules`) | | `keymap.modifierGestureStep` | Advance the double-Shift gesture recognizer by one event. The rule itself lives in `lithe-core-rules` so the Windows frontend can run it synchronously | | `editor.workbenchState.reduce` | Apply one editor-group state transition: open, close, move, pin, preview, merge or normalize. The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously inside store updates | +| `editor.groupLayout.reduce` | Apply one transition to the editor groups and the tree that divides the editor area among them: a workbench state action, opening a tab, dropping a dragged tab or removing empty groups (from `lithe-editor-layout`) | +| `editor.groupLayout.dropTargets` | Say which places of each editor group accept a dragged tab (from `lithe-editor-layout`) | | `workspace.classifyVisibility` | Classify a batch of workspace-relative paths as hidden or visible. The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously | | `workspace.visibilityDefaults` | Return the built-in hidden-path lists (from `lithe-core-rules`) | | `workbench.chromeLayout.reduce` | Apply one transition to the rearrangeable workbench chrome: stripe, title bar and status bar button placement and open tool panels (from `lithe-core-rules`) | @@ -557,7 +602,7 @@ or removing a command fails until the table changes in the same commit. | `editor.parseNavigationInput` | Parse a bounded line or line:column input into zero-based editor coordinates | | `editor.navigationHistory.reduce` | Reduce one bounded back/forward navigation history event and return the next state plus the location to open | | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | -| `network.proxyPolicy` | Normalize the stored proxy settings into the policy a network stack must apply (mode, server, credentials, exclusions) | +| `network.proxyPolicy` | Normalize the stored proxy settings into the policy a network stack must apply (mode, server, credentials, exclusion rules) | | `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | | `lsp.builtinCompletions` | Return lightweight current-file identifier completions | | `lsp.builtinHover` | Return lightweight current-symbol hover text | @@ -610,7 +655,7 @@ or removing a command fails until the table changes in the same commit. | `git.workspaceCommitStep` | Execute one guarded workspace commit/push | | `git.classifyMetadataChanges` | Classify a bounded page of coalesced watcher paths without filesystem access | | `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | -| `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | +| `git.worktrees` | Return deterministic registered-worktree metadata with each worktree's state and allowed writes; `countChanges` also counts each checkout's local changes | | `git.pullRequestContext` | Resolve worktree-aware PR branch defaults, publication state, and uncommitted-change state | | `git.command` | Execute one argument-based Git operation and return its arguments, streams, exit code, and ordered subprocess invocations | | `git.repositoryRoot` | Resolve the repository root containing a workspace path without acquiring repository write coordination | @@ -682,6 +727,8 @@ or removing a command fails until the table changes in the same commit. | `git.normalizeChangeTargets` | Drop targets already covered by a selected untracked summary | | `git.rebasePlanPreview` | Validate an interactive rebase draft and derives its messages | | `git.referenceTree` | Group one kind of loaded references into an ordered tree | +| `git.changedFileGroups` | Group the changed files of a commit, stash or shelf by folder, folders and files in natural order | +| `git.worktreeCreationPreview` | Derive the worktree creation dialog's reference choices, checked branch name, suggested destination and readiness without running Git | | `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | | `git.commit` | Return one structured commit by revision with its full message body | | `git.commitFiles` | Return files changed by one commit | @@ -741,6 +788,46 @@ capacities return `invalid_request`. Hosts own persistence and which roots are worth recording; menus may show fewer rows than are retained. See `rust/fixtures/workspace/recent-projects-v1.json`. +`workspace.rowSelection.reduce` accepts `{ rows, selection?, action }` and returns +`{ selection, targets, rejection? }`. `rows` are the paths of the rows the host +shows, top to bottom; the empty path is the workspace root. `selection` is +`{ selected?, marked?, anchor? }`: the selected row, the paths marked for a batch +(returned in path order) and the path a Shift click extends from. `action.kind` is +`choose { path }` (a plain click: select the row, drop the marks and the anchor), +`toggle { path }` (Cmd or Ctrl: mark or unmark the row, select it and make it the +anchor), `extend { path }` (Shift: mark every row from the anchor, else from the +selected row, else the clicked row alone, to the clicked row, keep the other marks +and select it) or `clear` (a batch is over: drop the marks and the anchor, keep the +selected row). The root is never marked: `toggle` and `extend` on it change nothing, +and a range that covers it skips it. Marks and an anchor of rows the host does not +show, such as rows inside a closed folder, stay. An action on a path that is not in +`rows` returns `rejection: "workspace.rowSelection.stale"` with the selection as +sent. `targets` is what a batch acts on: the marked paths without those inside a +marked folder, empty when nothing is marked. A marked or anchored root, more than +2^20 rows or marks, and unknown fields or actions return `invalid_request`. See +`rust/fixtures/workspace/row-selection-v1.json`. + +`workspace.projectTree.rows` accepts `{ nodes, selection?, top? }` and returns +`{ rows, selection, selectionMoved, topRow, targets }`. `nodes` are the project tree +nodes the host loaded with `workspace.listDirectories`, in preorder, each +`{ path, depth, open? }`: the first is the root `{ path: "", depth: 0 }`, and every +other node is one name inside the nearest node above it at one less depth; `open` +says the user opened that folder. A node shows as a row when every folder above it +is open; the root always shows. `rows` are the positions in `nodes` of the rows that +show, top to bottom. `selection` is that of `workspace.rowSelection.reduce`. A +selected node that is not loaded, or that a closed folder hides, gives the selection +to the nearest folder above it that shows (the root at last), and `selectionMoved` +says so; marks and an anchor whose node is not loaded are dropped, while marks inside +a closed folder stay. `top` is the path of the row at the top of the view before the +change, and `topRow` its row now, or `null` when it no longer shows. `targets` is as +in `workspace.rowSelection.reduce`. Nothing reads the disk, and a folder's children +are only those the host listed. Hosts ask once per change of the tree's shape (a +folder opens or closes, a listing arrives, a node goes away), never per paint. A +first node that is not the root, a node that skips a depth or is not inside the node +above it, a path listed twice, more than 2^20 nodes and the selection errors of +`workspace.rowSelection.reduce` return `invalid_request`. See +`rust/fixtures/workspace/project-tree-rows-v1.json`. + `workspace.repositories.repositories` is ordered with the containing workspace repository first when present, then repositories under the opened workspace by workspace containment, depth, and path. Each entry contains an absolute native @@ -788,11 +875,26 @@ Traversal checks cancellation between directories and entries. `git.worktrees.worktrees` is ordered with the primary worktree first and then by path. Each entry contains `path`, `head`, nullable `branch`, `isCurrent`, `isPrimary`, `isBare`, `isDetached`, `isLocked`, nullable `lockReason`, -`isPrunable`, and nullable `pruneReason`. The path is absolute because linked +`isPrunable`, nullable `pruneReason`, `status`, nullable `changes`, and +`actions`. The path is absolute because linked worktrees may live outside the opened workspace; clients must treat it as an opaque native boundary value and must not persist it as a portable identifier. Core reads the list with one porcelain operation and does not run status in -each checkout. +each checkout unless the request sets `countChanges: true`; then it runs status +(index-only changes included, wholly untracked directories summarized) in every +checkout that is there and not bare, and `changes` is the number of entries, +`null` when the checkout's status failed or was not read. `status` is the one +state a row shows, the first that holds of `missing` (prunable), `locked`, +`modified` (counted changes above zero), `current`, and `available`. +`actions` is `{ "remove": { "allowed", "reason" }, "lock": { "allowed", +"reason" } }`: removal is refused for the primary worktree +(`git_worktree_primary`), then the current one (`git_worktree_current`), a +missing checkout (`git_worktree_missing`, pruned instead) and a lock +(`git_worktree_locked`); changing the lock is refused for the primary worktree +and a missing checkout. Apart from `git_worktree_missing`, each reason is the +error code the write returns for the same refusal. Clients word the state and +the reasons and do not derive them. +`rust/fixtures/git/worktree-rules-v1.json` defines the rules. For a rename or copy, each change uses the destination as `path` and preserves the source as `originalPath`; platform mutations that act on the Git entry pass both paths back to Core. @@ -1577,6 +1679,49 @@ an unreferenced fragment. Requests over 20,000 commits fail with `invalid_reques `rust/fixtures/git/graph-projection-v1.json` defines the rules and `rust/fixtures/git/graph-idea/` holds the pinned IntelliJ oracles. +`git.changedFileGroups` groups the changed files of a commit, a stash or a shelf +by folder; it never starts Git. The request is `{ "paths": string[] }`, the +repository-relative `/`-separated paths in the order Git listed them, at most +200,000. The response is `{ "folders": [{ "path", "files" }] }`: every path lands +in the folder it names before its last `/` (`""` for the top of the repository), +and `files` are indices into `paths`. Folder paths compare one segment at a time +and file names compare by name, both in the project tree's natural order +(`rust/fixtures/workspace/file-name-order-v1.json`), so a folder comes right +before the folders inside it and `v0.1.4.md` before `v0.1.12.md`; names the +natural order cannot tell apart fall back to their raw text, then to request +order. Hosts group once per loaded list and own expansion, rows and drawing. +`rust/fixtures/git/changed-file-groups-v1.json` defines the rules. + +`git.worktreeCreationPreview` derives what the worktree creation dialog shows; +it never starts Git. The request has `root`, `mode` (`newBranch`, +`existingBranch` or `detached`, as `createWorktree` takes them), `kind` (the +reference kind listed, default `local`), `reference` (the held reference's +`fullName`, `""` for none), `name`, `requireName`, `revision`, `destination`, +`lastSuggestion` (the suggestion the destination last followed, `""` at first), +`references` (the `git.references` entries; fields beyond `fullName`, +`shortName`, `kind` and `isCurrent` are ignored) and `checkedOutBranches` (the +`branch` of every registered worktree); other top-level fields are +`invalid_request`. The response is `{ "choices", "reference", "branch", +"suggestedDestination", "destination", "destinationIsAbsolute", "ready" }`. +`choices` are indices into `references`: local branches no worktree has checked +out for `existingBranch`, references of `kind` otherwise. `reference` stays the +held one while it is a choice, else becomes the current local branch among the +choices, else the first, and is `null` without choices. `branch` is the +`git.branchNamePreview` response for `name` in `newBranch` mode and `null` +otherwise; a name over 1,024 characters fails the request. The suggestion sits +beside the repository as `-`, the leaf being the last `/` +segment of the cleaned branch name, the first nine characters of the trimmed +revision in `detached` mode, or the held reference's short name, with every +character other than a letter, a digit, `-` or `_` replaced by `-`, and +`worktree` when nothing is left. `destination` is the suggestion while the +request's `destination` equals `lastSuggestion` and the typed value otherwise. +`ready` needs an absolute `destination`, plus a reference and a cleaned name +without error for `newBranch`, a reference for `existingBranch`, and a +reference or a revision for `detached`. Hosts preview when an input changes, +again with `requireName: true` on submit, and keep `suggestedDestination` as the +next `lastSuggestion`. `rust/fixtures/git/worktree-creation-preview-v1.json` +defines the rules. + `git.integrationPreflight` accepts either `reference` or `gitReference` with `root` and `operation`, where `operation` is `merge`, `rebase`, `cherryPick`, or `revert`. It returns sorted, de-duplicated `blockingPaths` and @@ -2125,7 +2270,8 @@ network stack must apply. It accepts `{ "mode": "system" | "direct" | "username": string }, "exclusions"?: string[] }` (`port` must fit in 16 bits, otherwise the request is rejected with `invalid_request`) and returns `{ "policy": "system" | "direct" | "custom", "proxyType", "host", "port", -"username", "password", "exclusions", "configured" }`. +"username", "password", "exclusions", "exclusionRules", "invalidExclusions", +"configured" }`. An optional `passwordIdentity` binds a restored password to its saved proxy type, host, port and username; a mismatch removes the password. Host matching @@ -2143,6 +2289,35 @@ direct never carry credentials, so hosts can forward the plan to their network stack without a second credential-scoping decision. Deterministic cases are pinned by `rust/fixtures/network/proxy-policy-v1.json`. +Core alone reads what an exclusion entry means. For a custom policy, +`exclusionRules` lists the entries as `{ "kind", "value" }` in entry order, a +repeated rule once, and `invalidExclusions` lists the trimmed entries that have +none of the forms below; both are empty for system and direct. Entries are +compared lowercase and without one trailing dot: + +| Entry | `kind` | `value` | +| --- | --- | --- | +| `` | `local` | empty | +| `address/prefix` with a valid prefix length | `cidr` | network address with the host bits cleared, `/`, prefix length | +| an IPv4 or IPv6 address, IPv6 optionally in brackets | `ip` | canonical address | +| `*.domain` or `.domain` | `suffix` | `domain` | +| any other name | `host` | the name | + +A name must not be empty or contain white space, `*`, `/`, `:`, brackets, +angle brackets, or an empty label; an entry such as `*`, `*.`, `10.0.0.0/33` +or `example.com:8080` is invalid and sends no host direct. + +A request goes direct when its host, lowercase and without one trailing dot, +matches any rule; an empty host never does. `host` matches an equal host; +`suffix` matches the domain itself or a host ending with `.` and the domain; +`ip` matches a host that is an equal address of the same family; `cidr` +matches a host that is an address of the same family inside the block, so an +IPv4-mapped IPv6 host matches neither an IPv4 address nor an IPv4 block; an +IPv6 host may carry a non-empty `%zone`, which is not compared; `local` matches +a host that is not an address and contains no dot. Network stacks apply exactly this comparison +and replay `rust/fixtures/network/proxy-bypass-v1.json`, whose cases list the +rules of each exclusion list and which hosts go direct. + The `debug.*` commands are the shared Debug Adapter Protocol boundary. Rust owns DAP framing, request sequences, response correlation, initialization and execution state, deterministic breakpoint sets, and normalized thread, stack, @@ -4000,6 +4175,39 @@ successfully started session merely because other plugins failed discovery. This is metadata selection, not manifest validation, approval or admission. Shared cases are in `rust/fixtures/plugins/installed-versions.json`. +`plugins.installed.lifecycle` accepts `{ installed: [{ pluginID, version }], +choices?: [{ pluginID, enabled }], action?: { kind, pluginID } }`, with at most +4096 installed versions and 4096 choices. `installed` lists every installed +version and `choices` every enablement choice the user made; an empty ID or +version, a repeated version or a second choice for one plugin is +`invalid_request`. `kind` is `confirmInstall`, `enable`, `disable` or +`uninstall`. It returns `{ plugins: [{ pluginID, enabled }], changed, steps, +rejection? }`. `plugins` lists every installed plugin once, ordered by ID, with +its enablement once the steps ran; an uninstalled plugin is left out. +Enablement is per plugin, not per version, and a plugin without a choice is +disabled. A choice for a plugin that is not installed is kept for a later +installation. `changed` says the steps change the choice for the action's +plugin. An action on a plugin with no installed version returns +`rejection: "plugin.lifecycle.notInstalled"`, no steps and the unchanged +`plugins`. + +`steps` are what the host does on the action's plugin, in order: +`{ kind: "recordChoice", enabled }` records the choice, `{ kind: "forgetChoice" }` +drops it, `{ kind: "stopPrograms" }` stops the plugin's language server and +backend and returns once they ended or gave up, `{ kind: "removeVersion", +version }` removes one installed version and then forgets that version's +approval record (the record stays when the removal failed), and +`{ kind: "removeState" }` removes the state directory the plugin's programs +wrote. A failed step does not stop the steps after it; the host reports every +failure. `confirmInstall` records `enabled: true` only when the plugin has no +choice, so a plugin disabled before an update stays disabled. `enable` records +`true` unless already chosen. `disable` records `false` unless already chosen +and then stops the programs, so nothing starts the plugin again while it stops. +`uninstall` stops the programs, removes every installed version in version text +order, forgets the choice if there is one and removes the state, so a later +installation starts as a first one. Shared cases are in +`rust/fixtures/plugins/installed-lifecycle.json`. + `plugins.declarative.decodeGrammar` accepts `{ manifest, languageID, grammar }`, where `grammar` is the text the host read from the language's `grammar.path` inside the installed package, and returns `language: { id, fileExtensions, @@ -4707,8 +4915,11 @@ bridge exposes the same lifetime and result contract. Fixtures live in ### Process lifecycle decisions `processLifecycle.resolve` accepts `{ context, settings, records }` and returns -`{ gracefulStopMilliseconds, decisions }` with exactly one decision per record, -in request order. `context` is `quit`, `closeTerminalTab`, `agentDisconnect`, +`{ gracefulStopMilliseconds, gracefulStopRule, decisions }` with exactly one +decision per record, in request order. `gracefulStopRule` is +`{ defaultSeconds, minSeconds, maxSeconds }`, the default of +`settings.gracefulStopSeconds` and the limits it is clamped to, in seconds; +settings pages read it instead of copying the numbers. `context` is `quit`, `closeTerminalTab`, `agentDisconnect`, `crashRecovery`, `startupSweep`, `finalSweep`, or `closeWorkspace` (a project or window closes while Lithe keeps running). Each record carries a host-chosen `id`, a `kind` (`run`, `terminal`, `agentTask`, `languageServer`, `debugAdapter`, diff --git a/rust/crates/core/Cargo.toml b/rust/crates/core/Cargo.toml index ed058f8a..f58c45b1 100644 --- a/rust/crates/core/Cargo.toml +++ b/rust/crates/core/Cargo.toml @@ -22,6 +22,7 @@ lithe-diagnostics = { path = "../diagnostics" } lithe-diff = { path = "../diff" } lithe-document-uri = { path = "../document-uri" } lithe-editor = { path = "../editor" } +lithe-editor-layout = { path = "../editor-layout" } lithe-execution = { path = "../execution" } lithe-git = { path = "../git" } lithe-git-host = { path = "../git-host" } @@ -36,6 +37,7 @@ lithe-plugin-function-relay = { path = "../plugin-function-relay" } lithe-plugin-keymap-import = { path = "../plugin-keymap-import" } lithe-plugin-language-servers = { path = "../plugin-language-servers" } lithe-plugin-manifest = { path = "../plugin-manifest" } +lithe-plugin-lifecycle = { path = "../plugin-lifecycle" } lithe-plugin-marketplace = { path = "../plugin-marketplace" } lithe-plugin-model = { path = "../plugin-model" } lithe-plugin-native-ui = { path = "../plugin-native-ui" } @@ -45,7 +47,9 @@ lithe-plugin-web-content = { path = "../plugin-web-content" } lithe-process = { path = "../process" } lithe-project = { path = "../project" } lithe-project-generators = { path = "../project-generators" } +lithe-project-tree = { path = "../project-tree" } lithe-protocol = { path = "../protocol" } +lithe-row-selection = { path = "../row-selection" } lithe-request-context = { path = "../request-context" } lithe-terminal = { path = "../terminal" } lithe-text = { path = "../text" } diff --git a/rust/crates/core/src/protocol/command.rs b/rust/crates/core/src/protocol/command.rs index 69080a9f..83cfb07a 100644 --- a/rust/crates/core/src/protocol/command.rs +++ b/rust/crates/core/src/protocol/command.rs @@ -235,6 +235,9 @@ pub enum PluginsCommand { DeclarativeSettingsActionPlan, /// Selects numeric installed versions and reports malformed native facts. InstalledSelect, + /// Reports installed plugins' enablement and plans a lifecycle action + /// (`plugins.installed.lifecycle`). + InstalledLifecycle, /// Validates an atomic plugin settings edit and preserves unrelated values. DeclarativeEditSettings, /// Expands a language server's `workspaceConfiguration` (`plugins.declarative.workspaceConfiguration`). @@ -460,6 +463,12 @@ pub enum WorkspaceCommand { ReleaseIndexing, /// Lists the visible children of project directories (`workspace.listDirectories`). ListDirectories, + /// Says which loaded project tree nodes show as rows and keeps the selection on one + /// (`workspace.projectTree.rows`). + ProjectTreeRows, + /// Applies one click to the multi-selection of rows of workspace paths + /// (`workspace.rowSelection.reduce`). + RowSelectionReduce, /// Orders natively enumerated directory siblings (`workspace.orderEntries`). OrderEntries, /// Ranks host-supplied candidate names with the shared matcher (`workspace.rankNames`). @@ -533,6 +542,13 @@ pub enum EditorCommand { /// (`editor.workbenchState.reduce`). The rule lives in `lithe-core-rules` so the Windows frontend /// can run it synchronously inside store updates. WorkbenchStateReduce, + /// Applies one transition to the editor groups and the tree that divides the editor area + /// among them: a workbench state action, opening a tab, dropping a dragged one or removing + /// empty groups (`editor.groupLayout.reduce`). + GroupLayoutReduce, + /// Says which places of each editor group accept a dragged tab + /// (`editor.groupLayout.dropTargets`). + GroupLayoutDropTargets, /// Applies a deterministic line-level editing transform /// (`editor.lineEdit`). LineEdit, @@ -803,6 +819,11 @@ pub enum GitCommand { RebasePlanPreview, /// Groups one kind of loaded references into an ordered tree (`git.referenceTree`). ReferenceTree, + /// Groups changed files by folder in natural order (`git.changedFileGroups`). + ChangedFileGroups, + /// Derives the worktree creation dialog's choices, branch, destination and readiness + /// (`git.worktreeCreationPreview`). + WorktreeCreationPreview, /// Resolves the destination and commits for a branch push (`git.pushPreview`). PushPreview, /// Resolves metadata for one commit (`git.commit`). @@ -982,6 +1003,9 @@ impl CoreCommand { Some(Self::Plugins(PluginsCommand::DeclarativeSettingsActionPlan)) } "plugins.installed.select" => Some(Self::Plugins(PluginsCommand::InstalledSelect)), + "plugins.installed.lifecycle" => { + Some(Self::Plugins(PluginsCommand::InstalledLifecycle)) + } "plugins.declarative.editSettings" => { Some(Self::Plugins(PluginsCommand::DeclarativeEditSettings)) } @@ -1094,6 +1118,12 @@ impl CoreCommand { "workspace.refreshIndexing" => Some(Self::Workspace(WorkspaceCommand::RefreshIndexing)), "workspace.filterChanges" => Some(Self::Workspace(WorkspaceCommand::FilterChanges)), "workspace.listDirectories" => Some(Self::Workspace(WorkspaceCommand::ListDirectories)), + "workspace.projectTree.rows" => { + Some(Self::Workspace(WorkspaceCommand::ProjectTreeRows)) + } + "workspace.rowSelection.reduce" => { + Some(Self::Workspace(WorkspaceCommand::RowSelectionReduce)) + } "workspace.orderEntries" => Some(Self::Workspace(WorkspaceCommand::OrderEntries)), "workspace.rankNames" => Some(Self::Workspace(WorkspaceCommand::RankNames)), "workspace.planDuplicate" => Some(Self::Workspace(WorkspaceCommand::PlanDuplicate)), @@ -1157,6 +1187,10 @@ impl CoreCommand { "editor.workbenchState.reduce" => { Some(Self::Editor(EditorCommand::WorkbenchStateReduce)) } + "editor.groupLayout.reduce" => Some(Self::Editor(EditorCommand::GroupLayoutReduce)), + "editor.groupLayout.dropTargets" => { + Some(Self::Editor(EditorCommand::GroupLayoutDropTargets)) + } "workspace.classifyVisibility" => { Some(Self::Workspace(WorkspaceCommand::ClassifyVisibility)) } @@ -1367,6 +1401,8 @@ impl CoreCommand { "git.normalizeChangeTargets" => Some(Self::Git(GitCommand::NormalizeChangeTargets)), "git.rebasePlanPreview" => Some(Self::Git(GitCommand::RebasePlanPreview)), "git.referenceTree" => Some(Self::Git(GitCommand::ReferenceTree)), + "git.changedFileGroups" => Some(Self::Git(GitCommand::ChangedFileGroups)), + "git.worktreeCreationPreview" => Some(Self::Git(GitCommand::WorktreeCreationPreview)), "git.pushPreview" => Some(Self::Git(GitCommand::PushPreview)), "git.commit" => Some(Self::Git(GitCommand::Commit)), "git.commitFiles" => Some(Self::Git(GitCommand::CommitFiles)), diff --git a/rust/crates/core/src/runtime/dispatcher/editor.rs b/rust/crates/core/src/runtime/dispatcher/editor.rs index 293cf692..6ca0f5eb 100644 --- a/rust/crates/core/src/runtime/dispatcher/editor.rs +++ b/rust/crates/core/src/runtime/dispatcher/editor.rs @@ -1,4 +1,4 @@ -//! `editor.*` commands: workbench state, text edits, find, navigation and preferences. +//! `editor.*` commands: workbench state, group layout, text edits, find, navigation and preferences. use super::{plugin_response, rules_response}; use crate::protocol::{CoreError, CoreResponse, EditorCommand, ErrorCode}; @@ -15,6 +15,12 @@ pub(super) fn dispatch( EditorCommand::WorkbenchStateReduce => { rules_response(id, "editor.workbenchState.reduce", payload) } + EditorCommand::GroupLayoutReduce => { + plugin_response(id, payload, lithe_editor_layout::reduce_command) + } + EditorCommand::GroupLayoutDropTargets => { + plugin_response(id, payload, lithe_editor_layout::drop_targets_command) + } EditorCommand::Find => match lithe_editor::find::command(payload, document) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), diff --git a/rust/crates/core/src/runtime/dispatcher/git.rs b/rust/crates/core/src/runtime/dispatcher/git.rs index 4c3090f5..7de05cbb 100644 --- a/rust/crates/core/src/runtime/dispatcher/git.rs +++ b/rust/crates/core/src/runtime/dispatcher/git.rs @@ -435,6 +435,12 @@ pub(super) fn dispatch(command: GitCommand, id: Option, payload: Value) GitCommand::ReferenceTree => { plugin_response(id, payload, lithe_git::reference_tree_command) } + GitCommand::ChangedFileGroups => { + plugin_response(id, payload, lithe_git::changed_file_groups_command) + } + GitCommand::WorktreeCreationPreview => { + plugin_response(id, payload, lithe_git::worktree_creation_preview_command) + } GitCommand::PrepareCommitSelection => { plugin_response(id, payload, lithe_git::prepare_commit_selection_command) } diff --git a/rust/crates/core/src/runtime/dispatcher/plugins.rs b/rust/crates/core/src/runtime/dispatcher/plugins.rs index 87cdd3d9..60ba2fea 100644 --- a/rust/crates/core/src/runtime/dispatcher/plugins.rs +++ b/rust/crates/core/src/runtime/dispatcher/plugins.rs @@ -74,6 +74,9 @@ pub(super) fn dispatch( PluginsCommand::InstalledSelect => { plugin_response(id, payload, lithe_plugin_model::installed::select_command) } + PluginsCommand::InstalledLifecycle => { + plugin_response(id, payload, lithe_plugin_lifecycle::command) + } PluginsCommand::SettingsCatalog => { plugin_response(id, payload, lithe_plugin_manifest::settings_catalog_command) } diff --git a/rust/crates/core/src/runtime/dispatcher/workspace.rs b/rust/crates/core/src/runtime/dispatcher/workspace.rs index 9e0e6606..53923e9a 100644 --- a/rust/crates/core/src/runtime/dispatcher/workspace.rs +++ b/rust/crates/core/src/runtime/dispatcher/workspace.rs @@ -1,4 +1,5 @@ -//! `workspace.*` commands: snapshots, indexing, directory actions, repositories and search. +//! `workspace.*` commands: snapshots, indexing, project tree rows, row selection, directory +//! actions, repositories and search. use super::{plugin_response, rules_response}; use crate::project::{ @@ -93,6 +94,12 @@ pub(super) fn dispatch( ), } } + WorkspaceCommand::ProjectTreeRows => { + plugin_response(id, payload, lithe_project_tree::command) + } + WorkspaceCommand::RowSelectionReduce => { + plugin_response(id, payload, lithe_row_selection::command) + } WorkspaceCommand::ListDirectories => { match serde_json::from_value::(payload) .map_err(|error| { diff --git a/rust/crates/core/src/tests/git.rs b/rust/crates/core/src/tests/git.rs index 2175bfe7..0399558e 100644 --- a/rust/crates/core/src/tests/git.rs +++ b/rust/crates/core/src/tests/git.rs @@ -1846,6 +1846,37 @@ fn git_worktrees_lists_primary_linked_and_locked_metadata() { assert_eq!(worktrees[1]["branch"], "refs/heads/feature/linked"); assert_eq!(worktrees[1]["isLocked"], true); assert_eq!(worktrees[1]["lockReason"], "Codex task"); + assert_eq!(worktrees[0]["status"], "current"); + assert_eq!(worktrees[0]["changes"], Value::Null); + assert_eq!( + worktrees[0]["actions"]["remove"]["reason"], + "git_worktree_primary" + ); + assert_eq!(worktrees[1]["status"], "locked"); + assert_eq!( + worktrees[1]["actions"]["remove"]["reason"], + "git_worktree_locked" + ); + assert_eq!(worktrees[1]["actions"]["lock"]["allowed"], true); + + fs::write(root.join("draft.txt"), "draft\n").expect("untracked file should be written"); + let counted: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "git-worktrees-count", + "command": "git.worktrees", + "payload": { "root": root, "countChanges": true } + })) + .expect("worktrees request should encode"), + )) + .expect("worktrees response should be JSON"); + assert_eq!(counted["ok"], true, "{counted:?}"); + let counted = counted["data"]["worktrees"] + .as_array() + .expect("worktrees should be an array"); + assert_eq!(counted[0]["changes"], 1, "{counted:?}"); + assert_eq!(counted[0]["status"], "modified"); + assert_eq!(counted[1]["changes"], 0, "{counted:?}"); + assert_eq!(counted[1]["status"], "locked"); } #[test] diff --git a/rust/crates/core/src/tests/git_changed_file_groups.rs b/rust/crates/core/src/tests/git_changed_file_groups.rs new file mode 100644 index 00000000..8e38353e --- /dev/null +++ b/rust/crates/core/src/tests/git_changed_file_groups.rs @@ -0,0 +1,34 @@ +//! `git.changedFileGroups` against the shared fixture. + +use serde_json::{json, Value}; + +fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"groups", "command":"git.changedFileGroups", "payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_fixture_paths_have_one_grouping_and_order() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/git/changed-file-groups-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!( + response["data"]["folders"], case["folders"], + "{}", + case["name"] + ); + } +} + +#[test] +fn rejects_unknown_fields() { + let response = call(json!({"paths":["a"],"status":["M"]})); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); +} diff --git a/rust/crates/core/src/tests/git_worktree_creation_preview.rs b/rust/crates/core/src/tests/git_worktree_creation_preview.rs new file mode 100644 index 00000000..3653889e --- /dev/null +++ b/rust/crates/core/src/tests/git_worktree_creation_preview.rs @@ -0,0 +1,56 @@ +//! `git.worktreeCreationPreview` against the shared fixture. + +use serde_json::{json, Value}; + +fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"worktree", "command":"git.worktreeCreationPreview", "payload":payload}) + .to_string(), + )) + .unwrap() +} + +/// The fixture writes paths for Unix; on Windows an absolute one moves to drive `C:`. +fn native(value: &mut Value, fields: &[&str]) { + for field in fields { + if let Some(path) = value[*field].as_str().filter(|path| path.starts_with('/')) { + value[*field] = if cfg!(windows) { + Value::String(format!("C:{}", path.replace('/', "\\"))) + } else { + Value::String(path.to_string()) + }; + } + } +} + +#[test] +fn shared_fixture_derives_choices_destination_and_readiness() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/git/worktree-creation-preview-v1.json" + )) + .unwrap(); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + for case in cases { + let mut request = case["request"].clone(); + native(&mut request, &["root", "destination", "lastSuggestion"]); + let mut expected = case["response"].clone(); + native(&mut expected, &["suggestedDestination", "destination"]); + let response = call(request); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], expected, "{}", case["name"]); + } +} + +#[test] +fn shared_fixture_rejects_invalid_requests() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/git/worktree-creation-preview-v1.json" + )) + .unwrap(); + for case in fixture["invalidRequests"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], false, "{}", case["name"]); + assert_eq!(response["error"]["code"], case["code"], "{}", case["name"]); + } +} diff --git a/rust/crates/core/src/tests/group_layout.rs b/rust/crates/core/src/tests/group_layout.rs new file mode 100644 index 00000000..876efe5d --- /dev/null +++ b/rust/crates/core/src/tests/group_layout.rs @@ -0,0 +1,39 @@ +//! `editor.groupLayout.reduce` and `editor.groupLayout.dropTargets` against the shared editor +//! group layout fixture. + +use serde_json::{json, Value}; + +fn run(command: &str, request: &Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command": command, "payload": request}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_group_layout_fixture_runs_every_case() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/editor/group-layout-v1.json" + ))) + .unwrap(); + for (section, command) in [ + ("reduce", "editor.groupLayout.reduce"), + ("dropTargets", "editor.groupLayout.dropTargets"), + ] { + for case in fixture[section]["cases"].as_array().unwrap() { + let response = run(command, &case["request"]); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "case {}", case["name"]); + } + for case in fixture[section]["invalid"].as_array().unwrap() { + let response = run(command, &case["request"]); + assert_eq!(response["ok"], false, "case {}: {response}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "case {}", + case["name"] + ); + } + } +} diff --git a/rust/crates/core/src/tests/installed_lifecycle.rs b/rust/crates/core/src/tests/installed_lifecycle.rs new file mode 100644 index 00000000..9b9ad974 --- /dev/null +++ b/rust/crates/core/src/tests/installed_lifecycle.rs @@ -0,0 +1,33 @@ +//! `plugins.installed.lifecycle` against the shared plugin lifecycle fixture. + +use serde_json::{json, Value}; + +fn lifecycle(request: &Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command":"plugins.installed.lifecycle", "payload":request}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_plugin_lifecycle_fixture_plans_every_action() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/plugins/installed-lifecycle.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = lifecycle(&case["request"]); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "case {}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = lifecycle(&case["request"]); + assert_eq!(response["ok"], false, "case {}: {response}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "case {}", + case["name"] + ); + } +} diff --git a/rust/crates/core/src/tests/mod.rs b/rust/crates/core/src/tests/mod.rs index f2192b26..5f7bb6a7 100644 --- a/rust/crates/core/src/tests/mod.rs +++ b/rust/crates/core/src/tests/mod.rs @@ -14,6 +14,7 @@ mod file_name_order; mod file_operations; mod git; mod git_change_targets; +mod git_changed_file_groups; mod git_diff_blocks; mod git_fetch; mod git_fetch_plan; @@ -31,8 +32,11 @@ mod git_untracked_directories; mod git_update_plan; mod git_workspace_commit; mod git_workspace_status; +mod git_worktree_creation_preview; mod github; +mod group_layout; mod history_events; +mod installed_lifecycle; mod installed_versions; mod keymap_import; mod language_server_sessions; @@ -43,12 +47,14 @@ mod plugin_run_debug; mod plugin_settings_documents; mod project; mod project_generators; +mod project_tree; mod protocol; mod published_build_plugins; mod rank_names; #[cfg(unix)] mod remote_sessions; mod request_events; +mod row_selection; mod run_configuration; pub(crate) mod support; mod toolchain_definitions; diff --git a/rust/crates/core/src/tests/project_tree.rs b/rust/crates/core/src/tests/project_tree.rs new file mode 100644 index 00000000..66c832eb --- /dev/null +++ b/rust/crates/core/src/tests/project_tree.rs @@ -0,0 +1,33 @@ +//! `workspace.projectTree.rows` against the shared project tree rows fixture. + +use serde_json::{json, Value}; + +fn run(request: &Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command": "workspace.projectTree.rows", "payload": request}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_project_tree_rows_fixture_runs_every_case() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/workspace/project-tree-rows-v1.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = run(&case["request"]); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "case {}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = run(&case["request"]); + assert_eq!(response["ok"], false, "case {}: {response}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "case {}", + case["name"] + ); + } +} diff --git a/rust/crates/core/src/tests/row_selection.rs b/rust/crates/core/src/tests/row_selection.rs new file mode 100644 index 00000000..91782a1e --- /dev/null +++ b/rust/crates/core/src/tests/row_selection.rs @@ -0,0 +1,33 @@ +//! `workspace.rowSelection.reduce` against the shared row selection fixture. + +use serde_json::{json, Value}; + +fn run(request: &Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command": "workspace.rowSelection.reduce", "payload": request}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_row_selection_fixture_runs_every_case() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/workspace/row-selection-v1.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = run(&case["request"]); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "case {}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = run(&case["request"]); + assert_eq!(response["ok"], false, "case {}: {response}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "case {}", + case["name"] + ); + } +} diff --git a/rust/crates/editor-layout/Cargo.toml b/rust/crates/editor-layout/Cargo.toml new file mode 100644 index 00000000..00229b25 --- /dev/null +++ b/rust/crates/editor-layout/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-editor-layout" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "How the editor area is split among its tab groups: opening a tab, dropping a dragged tab and which places accept it" + +[dependencies] +lithe-core-rules = { path = "../core-rules" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/crates/editor-layout/src/lib.rs b/rust/crates/editor-layout/src/lib.rs new file mode 100644 index 00000000..cffc029b --- /dev/null +++ b/rust/crates/editor-layout/src/lib.rs @@ -0,0 +1,715 @@ +//! How the editor area is divided among its tab groups, and what opening a tab or dropping a +//! dragged one does to the groups and to that division. +//! +//! The layout is a binary tree: a leaf shows one group, and a split lays its two children side +//! by side (`row`) or one over the other. A host keeps the tree together with what its own +//! widgets need, such as the ratio of each splitter, and sends only the shape: groups by ID and +//! splits by a `node` number the host chose. Core never sees ratios, pixels or a drag in +//! progress; a split it makes gets the number the host offered, and the host starts its +//! splitter in the middle. +//! +//! `editor.groupLayout.reduce` runs one transition over the groups and the tree together: +//! +//! * any action of `editor.workbenchState.reduce`, after which a leaf whose group left the +//! groups leaves the tree and its sibling takes the room; +//! * `openTab`, which opens a tab in the group the host prefers while the tree shows it, else +//! in the first group of the tree, else in a new group that becomes the whole tree; +//! * `dropTab`, the release of a dragged tab on a place `editor.groupLayout.dropTargets` +//! offered; a place that no longer fits the groups is a rejection, never a different move; +//! * `removeEmptyGroups`, which drops named groups that have no tab. +//! +//! `editor.groupLayout.dropTargets` says, once per drag, which places of each group accept the +//! dragged tab. The host does the hit testing against what it painted. A group docked in a +//! tool window is never split and is never removed when its last tab leaves; only a dockable +//! tab may join it. Fixtures live in `rust/fixtures/editor/group-layout-v1.json`. + +use std::collections::HashSet; + +use lithe_core_rules::editor::{reduce, Action, EmptyGroups, Group, SelectionFallback, State}; +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Upper bound of groups and of layout leaves in one request. +const MAX_GROUPS: usize = 1024; + +/// Rejection of a drop whose member or place no longer fits the groups, as when a tab closed +/// or a group went away while the tab was dragged. +pub const STALE: &str = "editor.groupLayout.stale"; + +/// Rejection of a drop on a place the drop rules refuse. +pub const REFUSED: &str = "editor.groupLayout.refused"; + +/// The shape of the layout tree. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +enum Layout { + /// A leaf that shows one group. + Leaf(Leaf), + /// A split of the room between two children. + Split(Split), +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Leaf { + group: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Split { + /// The host's number for this split, unique in the tree. + node: u64, + /// The children are side by side; otherwise the first is over the second. + #[serde(default)] + row: bool, + first: Box, + second: Box, +} + +impl Layout { + fn leaf(group: &str) -> Self { + Layout::Leaf(Leaf { + group: group.to_string(), + }) + } + + fn contains(&self, group: &str) -> bool { + match self { + Layout::Leaf(leaf) => leaf.group == group, + Layout::Split(split) => split.first.contains(group) || split.second.contains(group), + } + } + + fn has_node(&self, node: u64) -> bool { + match self { + Layout::Leaf(_) => false, + Layout::Split(split) => { + split.node == node || split.first.has_node(node) || split.second.has_node(node) + } + } + } + + fn first_group(&self) -> &str { + match self { + Layout::Leaf(leaf) => &leaf.group, + Layout::Split(split) => split.first.first_group(), + } + } + + /// Checks that groups and split numbers are unique and groups not empty, counting leaves. + fn check( + &self, + groups: &mut HashSet, + nodes: &mut HashSet, + ) -> Result<(), CoreError> { + match self { + Layout::Leaf(leaf) => { + if leaf.group.is_empty() { + return Err(invalid("A layout leaf has an empty group")); + } + if !groups.insert(leaf.group.clone()) { + return Err(invalid("A group is in the layout twice")); + } + if groups.len() > MAX_GROUPS { + return Err(invalid("The layout has too many groups")); + } + Ok(()) + } + Layout::Split(split) => { + if !nodes.insert(split.node) { + return Err(invalid("A split number is in the layout twice")); + } + split.first.check(groups, nodes)?; + split.second.check(groups, nodes) + } + } + } +} + +/// The tree without the leaves `keep` refuses; a split that loses a child is replaced by the +/// other child, and losing every leaf leaves no tree. +fn prune(layout: Option, keep: &impl Fn(&str) -> bool) -> Option { + match layout? { + Layout::Leaf(leaf) => keep(&leaf.group).then_some(Layout::Leaf(leaf)), + Layout::Split(split) => { + let Split { + node, + row, + first, + second, + } = split; + match (prune(Some(*first), keep), prune(Some(*second), keep)) { + (Some(first), Some(second)) => Some(Layout::Split(Split { + node, + row, + first: Box::new(first), + second: Box::new(second), + })), + (Some(only), None) | (None, Some(only)) => Some(only), + (None, None) => None, + } + } + } +} + +/// The tree with the leaf of `target` replaced by split `node` that holds it and a leaf of +/// `added` on side `edge`. +fn split_leaf(layout: Layout, target: &str, added: &str, edge: Edge, node: u64) -> Layout { + match layout { + Layout::Leaf(leaf) if leaf.group == target => { + let fresh = Box::new(Layout::leaf(added)); + let old = Box::new(Layout::Leaf(leaf)); + let (first, second) = if edge.leading() { + (fresh, old) + } else { + (old, fresh) + }; + Layout::Split(Split { + node, + row: edge.side_by_side(), + first, + second, + }) + } + Layout::Leaf(leaf) => Layout::Leaf(leaf), + Layout::Split(split) => Layout::Split(Split { + node: split.node, + row: split.row, + first: Box::new(split_leaf(*split.first, target, added, edge, node)), + second: Box::new(split_leaf(*split.second, target, added, edge, node)), + }), + } +} + +/// A side of a group, where a dropped tab splits it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + fn side_by_side(self) -> bool { + matches!(self, Edge::Left | Edge::Right) + } + + /// The new group goes before the old one: to its left or above it. + fn leading(self) -> bool { + matches!(self, Edge::Left | Edge::Top) + } +} + +/// Where a dragged tab is released. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum DropPlace { + /// Into `group` as its tab at `index`, counted among its tabs without the dragged one. + Join { group: String, index: i64 }, + /// Into a new group on side `edge` of `group`. + Split { group: String, edge: Edge }, + /// Into the first group of an editor area that has none. + Empty, +} + +/// The transitions only this command has; every other `kind` is a workbench state action. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum LayoutAction { + /// Opens `member` and shows it. + OpenTab { + member: String, + /// The group the host would rather use, such as the one a file was last shown in. + #[serde(default)] + preferred_group: Option, + /// The ID of the group made when the layout has none. + new_group: String, + }, + /// Releases the dragged `member` on `target`. + DropTab { + member: String, + target: DropPlace, + #[serde(default)] + docked: Vec, + #[serde(default)] + dockable: bool, + /// The ID of the group a split or an empty editor makes. + #[serde(default)] + new_group: Option, + /// The number of the split a split makes. + #[serde(default)] + new_node: Option, + }, + /// Removes the named groups that have no tab; the others stay. + RemoveEmptyGroups { groups: Vec }, +} + +const LAYOUT_ACTIONS: [&str; 3] = ["openTab", "dropTab", "removeEmptyGroups"]; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReduceRequest { + #[serde(default)] + state: State, + #[serde(default)] + layout: Option, + action: Value, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReduceResponse { + state: State, + /// `null` when the editor area shows no group. + layout: Option, + /// Groups that left the state, in their previous order. + removed_group_ids: Vec, + /// The group the tab is in after `openTab` or `dropTab`. + #[serde(skip_serializing_if = "Option::is_none")] + group: Option, + /// Why a drop did not apply; the state and the layout are then as sent. + #[serde(skip_serializing_if = "Option::is_none")] + rejection: Option<&'static str>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TargetsRequest { + #[serde(default)] + state: State, + #[serde(default)] + layout: Option, + member: String, + #[serde(default)] + docked: Vec, + #[serde(default)] + dockable: bool, +} + +/// Which places of one group accept the dragged tab. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlaceRules { + /// The strip of tabs: the tab joins at the place it is dropped. + join: bool, + /// The edges of the body: the tab splits the group on that side. + split: bool, + /// The middle of the body: the tab joins at the end. + append: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GroupTargets { + group: String, + #[serde(flatten)] + rules: PlaceRules, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct TargetsResponse { + /// Every group of the state, in its order. + groups: Vec, + /// The editor area has no group, and the tab may open its first one. + empty: bool, +} + +/// Handles `editor.groupLayout.reduce`. +/// +/// The layout first loses the leaves of groups the state does not have. A rejected drop +/// returns the state and that layout without change. +pub fn reduce_command(payload: Value) -> Result { + let request: ReduceRequest = serde_json::from_value(payload).map_err(|error| { + invalid("Invalid editor group layout request").with_details(error.to_string()) + })?; + check(&request.state, request.layout.as_ref())?; + let ReduceRequest { + state, + layout, + action, + } = request; + let layout = prune(layout, &|group| has_group(&state, group)); + let mut run = Run { + state, + layout, + removed: Vec::new(), + group: None, + }; + let kind = action + .get("kind") + .and_then(Value::as_str) + .unwrap_or_default(); + if !LAYOUT_ACTIONS.contains(&kind) { + let action: Action = serde_json::from_value(action).map_err(|error| { + invalid("Invalid editor group layout action").with_details(error.to_string()) + })?; + run.apply(action); + return Ok(run.finish(None)); + } + let action: LayoutAction = serde_json::from_value(action).map_err(|error| { + invalid("Invalid editor group layout action").with_details(error.to_string()) + })?; + match action { + LayoutAction::OpenTab { + member, + preferred_group, + new_group, + } => run.open_tab(&member, preferred_group.as_deref(), &new_group)?, + LayoutAction::DropTab { + member, + target, + docked, + dockable, + new_group, + new_node, + } => { + if docked.len() > MAX_GROUPS { + return Err(invalid("Too many docked groups")); + } + let drop = Drop { + member: &member, + docked: &docked, + dockable, + new_group: new_group.as_deref(), + new_node, + }; + if let Err(rejection) = run.drop_tab(&drop, target)? { + return Ok(run.finish(Some(rejection))); + } + } + LayoutAction::RemoveEmptyGroups { groups } => run.remove_empty(&groups), + } + Ok(run.finish(None)) +} + +/// Handles `editor.groupLayout.dropTargets`. +/// +/// A tab that is in no group has no place. Otherwise, for each group: a group whose only tab +/// is the dragged one accepts nothing, since dropping it there changes nothing, and neither +/// does a docked group for a tab that is not dockable. Every other group accepts the tab in +/// its strip; its body accepts a split unless it is docked or not in the layout, and accepts +/// the tab at its end unless the tab is already one of its tabs. +pub fn drop_targets_command(payload: Value) -> Result { + let request: TargetsRequest = serde_json::from_value(payload).map_err(|error| { + invalid("Invalid editor drop targets request").with_details(error.to_string()) + })?; + check(&request.state, request.layout.as_ref())?; + if request.docked.len() > MAX_GROUPS { + return Err(invalid("Too many docked groups")); + } + let TargetsRequest { + state, + layout, + member, + docked, + dockable, + } = request; + let layout = prune(layout, &|group| has_group(&state, group)); + let open = group_of(&state, &member).is_some(); + let drop = Drop { + member: &member, + docked: &docked, + dockable, + new_group: None, + new_node: None, + }; + let groups = state + .groups + .iter() + .map(|group| GroupTargets { + group: group.id.clone(), + rules: if open { + drop.rules(group, layout.as_ref()) + } else { + PlaceRules::default() + }, + }) + .collect(); + Ok(encode(&TargetsResponse { + groups, + empty: open && layout.is_none(), + })) +} + +/// A drop's dragged tab and what the host offers for it. +struct Drop<'a> { + member: &'a str, + docked: &'a [String], + dockable: bool, + new_group: Option<&'a str>, + new_node: Option, +} + +impl Drop<'_> { + fn is_docked(&self, group: &str) -> bool { + self.docked.iter().any(|id| id == group) + } + + fn rules(&self, group: &Group, layout: Option<&Layout>) -> PlaceRules { + let docked = self.is_docked(&group.id); + let has = group.members.iter().any(|id| id == self.member); + let solo = has && group.members.iter().all(|id| id == self.member); + let refused = solo || (docked && !self.dockable); + PlaceRules { + join: !refused, + split: !refused && !docked && layout.is_some_and(|layout| layout.contains(&group.id)), + append: !refused && !has, + } + } + + /// What a group the tab leaves does when it is empty: a docked group stays. + fn source_policy(&self, group: &str) -> EmptyGroups { + if self.is_docked(group) { + EmptyGroups::Keep + } else { + EmptyGroups::Remove + } + } +} + +/// One transition in progress: the groups, the layout and what changed. +struct Run { + state: State, + layout: Option, + removed: Vec, + group: Option, +} + +impl Run { + fn apply(&mut self, action: Action) { + let reduction = reduce(std::mem::take(&mut self.state), action); + self.state = reduction.state; + self.removed.extend(reduction.removed_group_ids); + } + + /// Adds the empty group `id` after the others; it must be new. + fn add_group(&mut self, id: Option<&str>) -> Result { + let id = id.unwrap_or_default(); + if id.is_empty() || has_group(&self.state, id) { + return Err(invalid("The new group has no ID or already exists")); + } + if self.state.groups.len() >= MAX_GROUPS { + return Err(invalid("Too many editor groups")); + } + self.state.groups.push(Group { + id: id.to_string(), + ..Group::default() + }); + Ok(id.to_string()) + } + + fn open_tab( + &mut self, + member: &str, + preferred: Option<&str>, + new_group: &str, + ) -> Result<(), CoreError> { + if member.is_empty() { + return Err(invalid("The tab to open has no member")); + } + let group = match (&self.layout, preferred) { + (Some(layout), Some(preferred)) if layout.contains(preferred) => preferred.to_string(), + (Some(layout), _) => layout.first_group().to_string(), + (None, _) => { + let group = self.add_group(Some(new_group))?; + self.layout = Some(Layout::leaf(&group)); + group + } + }; + self.apply(Action::Open { + group: group.clone(), + member: member.to_string(), + activate: true, + }); + self.group = Some(group); + Ok(()) + } + + /// Carries a drop out, or names why it does not apply before changing anything. + fn drop_tab( + &mut self, + drop: &Drop, + target: DropPlace, + ) -> Result, CoreError> { + let Some(source) = group_of(&self.state, drop.member).map(|group| group.id.clone()) else { + return Ok(Err(STALE)); + }; + let member = drop.member.to_string(); + let move_to = |to: String, before: Option| Action::Move { + member: member.clone(), + from: source.clone(), + to, + before, + after: None, + activate: true, + empty_groups: drop.source_policy(&source), + selection_fallback: SelectionFallback::Adjacent, + }; + match target { + DropPlace::Join { group, index } => { + let Some(dest) = find(&self.state, &group) else { + return Ok(Err(STALE)); + }; + if !drop.rules(dest, self.layout.as_ref()).join { + return Ok(Err(REFUSED)); + } + if source == group { + let at = dest + .members + .iter() + .position(|id| *id == member) + .unwrap_or_default() as i64; + let to = index.clamp(0, dest.members.len() as i64 - 1); + if at != to { + self.apply(Action::Reorder { + group: group.clone(), + from_index: at, + to_index: to, + }); + } + self.apply(Action::Activate { + group: group.clone(), + member: Some(member.clone()), + }); + } else { + let before = usize::try_from(index.max(0)) + .ok() + .and_then(|index| dest.members.get(index)) + .cloned(); + self.apply(move_to(group.clone(), before)); + } + self.group = Some(group); + } + DropPlace::Split { group, edge } => { + let Some(dest) = find(&self.state, &group) else { + return Ok(Err(STALE)); + }; + if !self + .layout + .as_ref() + .is_some_and(|layout| layout.contains(&group)) + { + return Ok(Err(STALE)); + } + if !drop.rules(dest, self.layout.as_ref()).split { + return Ok(Err(REFUSED)); + } + let node = drop + .new_node + .filter(|node| { + !self + .layout + .as_ref() + .is_some_and(|layout| layout.has_node(*node)) + }) + .ok_or_else(|| invalid("The new split has no number or one already in use"))?; + let added = self.add_group(drop.new_group)?; + self.apply(move_to(added.clone(), None)); + let state = &self.state; + self.layout = prune(self.layout.take(), &|id| has_group(state, id)) + .map(|layout| split_leaf(layout, &group, &added, edge, node)); + self.group = Some(added); + } + DropPlace::Empty => { + if self.layout.is_some() { + return Ok(Err(STALE)); + } + let added = self.add_group(drop.new_group)?; + self.apply(move_to(added.clone(), None)); + self.layout = Some(Layout::leaf(&added)); + self.group = Some(added); + } + } + Ok(Ok(())) + } + + fn remove_empty(&mut self, named: &[String]) { + let groups = std::mem::take(&mut self.state.groups); + for group in groups { + if group.members.is_empty() && named.contains(&group.id) { + self.removed.push(group.id); + } else { + self.state.groups.push(group); + } + } + if self + .state + .focused_group + .as_ref() + .is_some_and(|focused| self.removed.contains(focused)) + { + self.state.focused_group = self.state.groups.first().map(|group| group.id.clone()); + } + } + + fn finish(self, rejection: Option<&'static str>) -> Value { + let Run { + state, + layout, + removed, + group, + } = self; + let layout = prune(layout, &|id| has_group(&state, id)); + encode(&ReduceResponse { + state, + layout, + removed_group_ids: removed, + group, + rejection, + }) + } +} + +fn check(state: &State, layout: Option<&Layout>) -> Result<(), CoreError> { + if state.groups.len() > MAX_GROUPS { + return Err(invalid("Too many editor groups")); + } + let mut ids = HashSet::new(); + for group in &state.groups { + if group.id.is_empty() || !ids.insert(group.id.as_str()) { + return Err(invalid("An editor group has an empty or repeated ID")); + } + } + if let Some(layout) = layout { + layout.check(&mut HashSet::new(), &mut HashSet::new())?; + } + Ok(()) +} + +fn has_group(state: &State, id: &str) -> bool { + find(state, id).is_some() +} + +fn find<'a>(state: &'a State, id: &str) -> Option<&'a Group> { + state.groups.iter().find(|group| group.id == id) +} + +fn group_of<'a>(state: &'a State, member: &str) -> Option<&'a Group> { + state + .groups + .iter() + .find(|group| group.members.iter().any(|id| id == member)) +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn encode(value: &impl Serialize) -> Value { + serde_json::to_value(value).expect("editor group layout response encodes") +} diff --git a/rust/crates/git/src/changed_file_groups.rs b/rust/crates/git/src/changed_file_groups.rs new file mode 100644 index 00000000..0c0818c3 --- /dev/null +++ b/rust/crates/git/src/changed_file_groups.rs @@ -0,0 +1,126 @@ +//! Groups the changed files of a commit, a stash or a shelf by folder, in the order the +//! changed-file lists show them. +//! +//! The grouping is a pure projection of repository-relative paths: a file belongs to the +//! folder its path names before the last `/`, the top of the repository being the folder +//! `""`. Folders sort by their path one segment at a time, and files by name, both in the +//! project tree's natural order (`lithe_core_rules::file_names`), so `v0.1.4` comes before +//! `v0.1.12` and a folder comes right before its subfolders. Which folders are open, the rows +//! they make and how they draw stay in the views. + +use std::cmp::Ordering; +use std::collections::HashMap; + +use lithe_core_rules::file_names::FileNameKey; +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Upper bound for one grouping; Git lists for larger changes are paged by the host. +const MAX_PATHS: usize = 200_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + /// Repository-relative paths with `/` separators, in the order Git listed them. + paths: Vec, +} + +/// One folder and the files directly in it. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Folder { + /// Path of the folder from the top of the repository; `""` for the top itself. + path: String, + /// Indices into the request's `paths` of the files in the folder, in name order. + files: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + folders: Vec, +} + +/// Handles `git.changedFileGroups`. +/// +/// Every path lands in exactly one folder. Folder paths compare segment by segment in natural +/// name order, a folder before the folders inside it; file names compare in the same order. A +/// tie of names that differ only where the natural order sees no difference falls back to the +/// raw text, and a repeated path keeps the order of the request. +pub fn command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git changed file groups request", + ) + .with_details(error.to_string()) + })?; + if request.paths.len() > MAX_PATHS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many changed files for one grouping", + )); + } + let mut at: HashMap<&str, usize> = HashMap::new(); + let mut groups: Vec<(&str, Vec)> = Vec::new(); + for (index, path) in request.paths.iter().enumerate() { + let folder = folder_of(path); + let group = *at.entry(folder).or_insert_with(|| { + groups.push((folder, Vec::new())); + groups.len() - 1 + }); + groups[group].1.push(index); + } + let mut keyed = groups + .into_iter() + .map(|(folder, files)| { + let mut named = files + .into_iter() + .map(|index| { + let name = name_of(&request.paths[index]); + (FileNameKey::new(name), name, index) + }) + .collect::>(); + named.sort(); + let files = named.into_iter().map(|(_, _, index)| index).collect(); + let segments = folder + .split('/') + .filter(|segment| !segment.is_empty()) + .map(FileNameKey::new) + .collect::>(); + (segments, folder, files) + }) + .collect::>(); + keyed.sort_by(|(left_key, left, _), (right_key, right, _)| { + compare_segments(left_key, right_key).then_with(|| left.cmp(right)) + }); + let folders = keyed + .into_iter() + .map(|(_, folder, files)| Folder { + path: folder.to_string(), + files, + }) + .collect(); + Ok(serde_json::to_value(Response { folders }).expect("changed file groups encode")) +} + +/// The folder `path` names before its last `/`, or `""` for a file at the top. +fn folder_of(path: &str) -> &str { + path.rfind('/').map_or("", |end| &path[..end]) +} + +/// The name `path` ends with after its last `/`. +fn name_of(path: &str) -> &str { + path.rfind('/').map_or(path, |end| &path[end + 1..]) +} + +/// Natural order of folder paths given as segments: the first differing segment decides, and a +/// folder comes before the folders inside it. +fn compare_segments(left: &[FileNameKey], right: &[FileNameKey]) -> Ordering { + left.iter() + .zip(right) + .map(|(left, right)| left.cmp(right)) + .find(|order| order.is_ne()) + .unwrap_or_else(|| left.len().cmp(&right.len())) +} diff --git a/rust/crates/git/src/contracts.rs b/rust/crates/git/src/contracts.rs index 6ef523aa..b91ad376 100644 --- a/rust/crates/git/src/contracts.rs +++ b/rust/crates/git/src/contracts.rs @@ -100,6 +100,13 @@ pub struct GitWorktreeResponse { pub(crate) is_prunable: bool, /// Git's explanation for why the registration can be pruned. pub prune_reason: Option, + /// The one state the worktree's row shows (`crate::worktree_rules::status`). + pub(crate) status: crate::worktree_rules::GitWorktreeStatus, + /// Number of local changes of the checkout; `null` when they were not counted or could + /// not be read. + pub(crate) changes: Option, + /// Which writes the worktree allows (`crate::worktree_rules::actions`). + pub(crate) actions: crate::worktree_rules::GitWorktreeActions, } #[derive(Debug, Clone, Serialize)] diff --git a/rust/crates/git/src/lib.rs b/rust/crates/git/src/lib.rs index 47ededc7..d27aa254 100644 --- a/rust/crates/git/src/lib.rs +++ b/rust/crates/git/src/lib.rs @@ -12,6 +12,7 @@ mod authentication_failure; mod branch_names; pub mod catalog; mod change_targets; +mod changed_file_groups; mod changelists; mod command_trace; mod commit_message_context; @@ -67,6 +68,7 @@ mod stash; mod status; mod untracked_directories; mod update_plan; +mod worktree_rules; mod worktrees; mod write; @@ -82,6 +84,7 @@ pub use setup::{ }; pub use change_targets::command as normalize_change_targets_command; +pub use changed_file_groups::command as changed_file_groups_command; pub use changelists::changelists_command; pub use commit_message_context::commit_message_context_command; pub use commit_selection::prepare_commit_selection_command; @@ -121,6 +124,7 @@ pub use repository_colors::assign_command as repository_color_assignments_comman pub use rollback::{rollback_plan_command, rollback_prepare_command}; pub use shelf::shelf_command; pub use update_plan::command as update_plan_command; +pub use worktree_rules::creation_preview_command as worktree_creation_preview_command; /// Internals the `git.*` integration tests in `lithe-core` assert against. #[cfg(feature = "test-support")] diff --git a/rust/crates/git/src/requests.rs b/rust/crates/git/src/requests.rs index fd044a7e..212c08ad 100644 --- a/rust/crates/git/src/requests.rs +++ b/rust/crates/git/src/requests.rs @@ -46,6 +46,10 @@ pub struct GitWatchContextRequest { /// Request for all worktrees registered in the current repository. pub struct GitWorktreesRequest { pub(crate) root: String, + /// Also reads the local changes of every checkout that is there and not bare, which sets + /// each entry's `changes` and lets its `status` be `modified`. + #[serde(default)] + pub(crate) count_changes: bool, } #[derive(Debug, Deserialize)] diff --git a/rust/crates/git/src/worktree_rules.rs b/rust/crates/git/src/worktree_rules.rs new file mode 100644 index 00000000..896eaf23 --- /dev/null +++ b/rust/crates/git/src/worktree_rules.rs @@ -0,0 +1,417 @@ +//! What a registered worktree shows and allows, and what the dialog that creates one suggests. +//! +//! The Worktrees tab of every client shows one state per worktree and offers removal and +//! locking only where Git and Core accept them. Those rules live here so clients only map the +//! state and the reason codes to their words: `status` ranks what is true of a worktree, +//! `actions` says which writes Core would refuse and with which code, and `creation_preview` +//! keeps the creation dialog's choices, branch name, suggested destination and readiness. +//! Fixtures live in `rust/fixtures/git/worktree-rules-v1.json` and +//! `rust/fixtures/git/worktree-creation-preview-v1.json`. + +use std::path::Path; + +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::branch_names::{ + preview as branch_name_preview, GitBranchNamePreviewRequest, GitBranchNamePreviewResponse, + GitBranchNameReference, GitBranchNameReferenceKind, +}; + +/// The one state a worktree's row shows, the most pressing of what is true of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GitWorktreeStatus { + /// Git reports the checkout gone; its registration can be pruned. + Missing, + /// The worktree is locked. + Locked, + /// The checkout has local changes; only known when they were counted. + Modified, + /// The worktree is the one the request was made from. + Current, + /// None of the above. + Available, +} + +/// Why Core refuses a write to a worktree. The first three are the error codes the write +/// returns for the same refusal; Core lets Git decide on a missing checkout, which clients +/// prune instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GitWorktreeBlock { + /// The primary worktree can neither be removed nor locked. + #[serde(rename = "git_worktree_primary")] + Primary, + /// The worktree the request was made from cannot remove itself. + #[serde(rename = "git_worktree_current")] + Current, + /// A locked worktree is unlocked before it is removed. + #[serde(rename = "git_worktree_locked")] + Locked, + /// The checkout is gone; pruning drops the registration. + #[serde(rename = "git_worktree_missing")] + Missing, +} + +/// Whether one write to a worktree can run, and why not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeAction { + /// The write can be offered. + pub allowed: bool, + /// Why it cannot; `null` when it can. + pub reason: Option, +} + +impl GitWorktreeAction { + fn from(reason: Option) -> Self { + Self { + allowed: reason.is_none(), + reason, + } + } +} + +/// The writes a worktree's menu and details offer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeActions { + /// Removing the worktree with `removeWorktree`. + pub remove: GitWorktreeAction, + /// Locking or unlocking it with `lockWorktree` or `unlockWorktree`. + pub lock: GitWorktreeAction, +} + +/// The registration facts the rules read. +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorktreeFacts { + #[serde(default)] + pub(crate) is_primary: bool, + #[serde(default)] + pub(crate) is_current: bool, + #[serde(default)] + pub(crate) is_locked: bool, + #[serde(default)] + pub(crate) is_prunable: bool, +} + +/// The state of a worktree with `changes` local changes, `None` while they were not counted: +/// a missing checkout, then a lock, local changes, being the current worktree, and otherwise +/// available. +pub(crate) fn status(facts: WorktreeFacts, changes: Option) -> GitWorktreeStatus { + if facts.is_prunable { + GitWorktreeStatus::Missing + } else if facts.is_locked { + GitWorktreeStatus::Locked + } else if changes.is_some_and(|changes| changes > 0) { + GitWorktreeStatus::Modified + } else if facts.is_current { + GitWorktreeStatus::Current + } else { + GitWorktreeStatus::Available + } +} + +/// Which writes the worktree allows. Removal is refused for the primary worktree, then the +/// current one, a missing checkout and a lock, in that order; changing the lock for the +/// primary worktree and a missing checkout. +pub(crate) fn actions(facts: WorktreeFacts) -> GitWorktreeActions { + let remove = if facts.is_primary { + Some(GitWorktreeBlock::Primary) + } else if facts.is_current { + Some(GitWorktreeBlock::Current) + } else if facts.is_prunable { + Some(GitWorktreeBlock::Missing) + } else if facts.is_locked { + Some(GitWorktreeBlock::Locked) + } else { + None + }; + let lock = if facts.is_primary { + Some(GitWorktreeBlock::Primary) + } else if facts.is_prunable { + Some(GitWorktreeBlock::Missing) + } else { + None + }; + GitWorktreeActions { + remove: GitWorktreeAction::from(remove), + lock: GitWorktreeAction::from(lock), + } +} + +/// Last part of a suggested destination when nothing usable names it. +const LEAF_FALLBACK: &str = "worktree"; +/// Characters of a typed revision a suggested destination is named after. +const REVISION_LEAF_CHARS: usize = 9; +/// Upper bound of the references one preview reads. +const MAX_REFERENCES: usize = 1_000_000; + +/// How the dialog creates the worktree, as `writeWorktree`'s `mode` names it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum CreationMode { + NewBranch, + ExistingBranch, + Detached, +} + +/// One reference the host loaded with `git.references`; other fields are ignored. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreationReference { + full_name: String, + short_name: String, + kind: GitBranchNameReferenceKind, + #[serde(default)] + is_current: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CreationPreviewRequest { + /// Root of the repository the worktree is created from. + root: String, + mode: CreationMode, + /// Kind of reference listed while the mode is not `existingBranch`. + #[serde(default = "local_kind")] + kind: GitBranchNameReferenceKind, + /// Full name of the reference the dialog holds; empty for none. + #[serde(default)] + reference: String, + /// New branch name as typed. + #[serde(default)] + name: String, + /// `true` on submit, where an empty branch name is an error. + #[serde(default)] + require_name: bool, + /// Revision as typed, for a detached worktree. + #[serde(default)] + revision: String, + /// Destination as typed. + #[serde(default)] + destination: String, + /// The suggestion the destination last followed, empty before the first preview. While + /// the destination still holds it, the user has not edited it and it follows the new one. + #[serde(default)] + last_suggestion: String, + references: Vec, + /// Full names of the branches registered worktrees have checked out. + #[serde(default)] + checked_out_branches: Vec, +} + +fn local_kind() -> GitBranchNameReferenceKind { + GitBranchNameReferenceKind::Local +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreationPreviewResponse { + /// Indices into the request's references the dialog lists, in their order. + choices: Vec, + /// The reference the dialog holds after the inputs changed; `null` without choices. + reference: Option, + /// The cleaned and checked branch name for `newBranch`, `null` for the other modes. + branch: Option, + /// Where the inputs suggest the checkout goes; the next preview's `lastSuggestion`. + suggested_destination: String, + /// What the destination field holds: the suggestion while the field held the last one, + /// else the typed destination. + destination: String, + /// The destination, trimmed, is an absolute path. + destination_is_absolute: bool, + /// The dialog can create the worktree. + ready: bool, +} + +/// Handles `git.worktreeCreationPreview`. +/// +/// `existingBranch` lists the local branches no worktree has checked out, the other modes the +/// references of `kind`. The held reference stays while it is listed, else the current local +/// branch among the choices is taken, else the first choice. The suggested destination sits +/// beside the repository, named `-`, the leaf being the last `/` segment of +/// the new branch, the first nine characters of a typed revision or the held reference's +/// short name, with every character that is not a letter, a digit, `-` or `_` replaced by `-`, +/// and `worktree` when nothing is left. The destination follows the suggestion for as long as +/// it holds the last one, that is until the user edits it. +pub fn creation_preview_command(payload: Value) -> Result { + let request: CreationPreviewRequest = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git worktree creation preview request", + ) + .with_details(error.to_string()) + })?; + if request.references.len() > MAX_REFERENCES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many references for one worktree creation preview", + )); + } + let existing = request.mode == CreationMode::ExistingBranch; + let kind = if existing { + GitBranchNameReferenceKind::Local + } else { + request.kind + }; + let choices: Vec = request + .references + .iter() + .enumerate() + .filter(|(_, reference)| reference.kind == kind) + .filter(|(_, reference)| { + !existing || !request.checked_out_branches.contains(&reference.full_name) + }) + .map(|(index, _)| index) + .collect(); + let held = choices + .iter() + .copied() + .find(|&index| request.references[index].full_name == request.reference) + .or_else(|| { + choices.iter().copied().find(|&index| { + let reference = &request.references[index]; + reference.kind == GitBranchNameReferenceKind::Local && reference.is_current + }) + }) + .or_else(|| choices.first().copied()) + .map(|index| &request.references[index]); + + let branch = match request.mode { + CreationMode::NewBranch => Some(branch_name_preview(GitBranchNamePreviewRequest { + raw_name: request.name.clone(), + require_name: request.require_name, + references: request + .references + .iter() + .map(|reference| GitBranchNameReference { + kind: reference.kind, + short_name: reference.short_name.clone(), + }) + .collect(), + reference: None, + })?), + _ => None, + }; + let revision = request.revision.trim(); + let source: String = match (&branch, request.mode) { + (Some(branch), _) if !branch.cleaned_name.is_empty() => branch.cleaned_name.clone(), + (_, CreationMode::Detached) if !revision.is_empty() => { + revision.chars().take(REVISION_LEAF_CHARS).collect() + } + _ => held + .map(|reference| reference.short_name.clone()) + .unwrap_or_default(), + }; + let suggested_destination = beside_repository(&request.root, &source); + let destination = if request.destination == request.last_suggestion { + suggested_destination.clone() + } else { + request.destination.clone() + }; + let destination_is_absolute = Path::new(destination.trim()).is_absolute(); + let ready = destination_is_absolute + && match request.mode { + CreationMode::NewBranch => { + held.is_some() + && branch.as_ref().is_some_and(|branch| { + branch.error.is_none() && !branch.cleaned_name.is_empty() + }) + } + CreationMode::ExistingBranch => held.is_some(), + CreationMode::Detached => held.is_some() || !revision.is_empty(), + }; + let response = CreationPreviewResponse { + choices, + reference: held.map(|reference| reference.full_name.clone()), + branch, + suggested_destination, + destination, + destination_is_absolute, + ready, + }; + Ok(serde_json::to_value(response).expect("worktree creation preview encodes")) +} + +/// The last part of a destination named after `source`. +fn leaf(source: &str) -> String { + let last = source.rsplit('/').next().unwrap_or_default(); + let leaf: String = last + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + if leaf.is_empty() { + LEAF_FALLBACK.to_string() + } else { + leaf + } +} + +/// The checkout beside the repository at `root` named after it and the leaf of `source`. +fn beside_repository(root: &str, source: &str) -> String { + let root = Path::new(root); + let name = root + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let beside = root.parent().unwrap_or(root); + beside + .join(format!("{name}-{}", leaf(source))) + .to_string_lossy() + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Case { + name: String, + worktree: WorktreeFacts, + changes: Option, + status: GitWorktreeStatus, + actions: GitWorktreeActions, + } + + #[derive(Deserialize)] + struct Fixture { + cases: Vec, + } + + #[test] + fn shared_fixture_ranks_states_and_names_refusals() { + let fixture: Fixture = + serde_json::from_str(include_str!("../../../fixtures/git/worktree-rules-v1.json")) + .expect("worktree rules fixture decodes"); + assert!(!fixture.cases.is_empty()); + for case in fixture.cases { + assert_eq!( + status(case.worktree, case.changes), + case.status, + "{}", + case.name + ); + assert_eq!(actions(case.worktree), case.actions, "{}", case.name); + } + } + + #[test] + fn leaf_keeps_letters_digits_dashes_and_underscores() { + assert_eq!(leaf("feature/login"), "login"); + assert_eq!(leaf("fix bug#12"), "fix-bug-12"); + assert_eq!(leaf("版本"), "版本"); + assert_eq!(leaf(""), LEAF_FALLBACK); + assert_eq!(leaf("refs/heads/"), LEAF_FALLBACK); + } +} diff --git a/rust/crates/git/src/worktrees.rs b/rust/crates/git/src/worktrees.rs index 83da44b0..ba953df9 100644 --- a/rust/crates/git/src/worktrees.rs +++ b/rust/crates/git/src/worktrees.rs @@ -3,11 +3,34 @@ use super::*; /// Returns one metadata-only snapshot for every registered worktree. +/// +/// Each entry carries the state its row shows and the writes it allows. With `countChanges` +/// Core also runs status in every checkout that is there and not bare; a checkout whose +/// status fails keeps `changes: null` and is ranked as if it had none. pub fn worktrees(request: GitWorktreesRequest) -> Result { let root = validate_root(&request.root)?; - Ok(GitWorktreesResponse { - worktrees: list_worktrees(&root)?, - }) + let mut worktrees = list_worktrees(&root)?; + for worktree in &mut worktrees { + let facts = worktree_rules::WorktreeFacts { + is_primary: worktree.is_primary, + is_current: worktree.is_current, + is_locked: worktree.is_locked, + is_prunable: worktree.is_prunable, + }; + if request.count_changes && !worktree.is_prunable && !worktree.is_bare { + worktree.changes = status(GitStatusRequest { + root: worktree.path.clone(), + include_index_only_changes: true, + repository_roots: Vec::new(), + summarize_untracked_directories: true, + }) + .ok() + .map(|status| status.changes.len()); + } + worktree.status = worktree_rules::status(facts, worktree.changes); + worktree.actions = worktree_rules::actions(facts); + } + Ok(GitWorktreesResponse { worktrees }) } fn list_worktrees(root: &str) -> Result, CoreError> { @@ -122,6 +145,9 @@ fn parse_worktree_record( lock_reason: lock.flatten(), is_prunable: prunable.is_some(), prune_reason: prunable.flatten(), + status: worktree_rules::GitWorktreeStatus::Available, + changes: None, + actions: worktree_rules::actions(worktree_rules::WorktreeFacts::default()), }) } diff --git a/rust/crates/network/src/exclusions.rs b/rust/crates/network/src/exclusions.rs new file mode 100644 index 00000000..60e36722 --- /dev/null +++ b/rust/crates/network/src/exclusions.rs @@ -0,0 +1,236 @@ +//! What each proxy exclusion entry means, and which hosts it sends direct. +//! +//! The settings page promises five forms: an exact host, a domain suffix written +//! `.example.com` or `*.example.com`, an IP address, a CIDR block, and `` for names +//! without a dot. This module is the only place that reads those forms. `parse` turns the +//! entries into rules a network stack compares with plain equality, suffix and address-block +//! checks; `bypasses` is that comparison, which hosts repeat exactly as the contract states. +//! Fixtures live in `rust/fixtures/network/proxy-bypass-v1.json`. + +use std::net::{IpAddr, Ipv6Addr}; + +use serde::Serialize; + +/// The form of one exclusion rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProxyExclusionKind { + /// The host equals `value`. + Host, + /// The host is `value` or ends with `.` followed by `value`. + Suffix, + /// The host is an IP address equal to `value`. + Ip, + /// The host is an IP address inside the block `value`. + Cidr, + /// The host is a name without a dot; `value` is empty. + Local, +} + +/// One exclusion entry in the form a network stack compares. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyExclusionRule { + /// How the host is compared with `value`. + pub kind: ProxyExclusionKind, + /// Lowercase host or domain without a trailing dot, canonical IP address, or canonical + /// network address with its prefix length; empty for `local`. + pub value: String, +} + +/// The exclusion entries of one policy, read. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ParsedExclusions { + /// Rules of the entries that have one of the five forms, in entry order. + pub rules: Vec, + /// Trimmed entries that have none of the forms; they send no host direct. + pub invalid: Vec, +} + +/// Reads `entries`, which are trimmed and not empty. A repeated rule is kept once. +pub fn parse(entries: &[String]) -> ParsedExclusions { + let mut parsed = ParsedExclusions::default(); + for entry in entries { + match rule(entry) { + Some(rule) => { + if !parsed.rules.contains(&rule) { + parsed.rules.push(rule); + } + } + None => parsed.invalid.push(entry.clone()), + } + } + parsed +} + +/// Whether `host`, the host name or address of a request URL without brackets, goes direct +/// under `rules`. The host is compared lowercase and without a trailing dot; an empty host +/// never goes direct. +pub fn bypasses(host: &str, rules: &[ProxyExclusionRule]) -> bool { + let host = normalized_name(host); + if host.is_empty() { + return false; + } + let address = host_address(&host); + rules.iter().any(|rule| match rule.kind { + ProxyExclusionKind::Host => host == rule.value, + ProxyExclusionKind::Suffix => { + host == rule.value + || host + .strip_suffix(rule.value.as_str()) + .is_some_and(|rest| rest.ends_with('.')) + } + ProxyExclusionKind::Ip => address.is_some_and(|address| { + rule.value + .parse::() + .is_ok_and(|value| value == address) + }), + ProxyExclusionKind::Cidr => { + address.is_some_and(|address| block(&rule.value).is_some_and(|b| b.contains(address))) + } + ProxyExclusionKind::Local => address.is_none() && !host.contains('.'), + }) +} + +/// The address `host` writes, an IPv6 address being allowed a non-empty `%zone`, which is not +/// compared; `None` for a name. +fn host_address(host: &str) -> Option { + if let Ok(address) = host.parse::() { + return Some(address); + } + let (address, zone) = host.split_once('%')?; + if zone.is_empty() { + return None; + } + address.parse::().ok().map(IpAddr::V6) +} + +/// The rule of one entry, or `None` when it has none of the five forms. +fn rule(entry: &str) -> Option { + let lowered = normalized_name(entry); + if lowered == "" { + return Some(ProxyExclusionRule { + kind: ProxyExclusionKind::Local, + value: String::new(), + }); + } + if lowered.contains('/') { + let block = block(&lowered)?; + return Some(ProxyExclusionRule { + kind: ProxyExclusionKind::Cidr, + value: block.to_string(), + }); + } + let unbracketed = lowered + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .unwrap_or(&lowered); + if let Ok(address) = unbracketed.parse::() { + return Some(ProxyExclusionRule { + kind: ProxyExclusionKind::Ip, + value: address.to_string(), + }); + } + if let Some(domain) = lowered + .strip_prefix("*.") + .or_else(|| lowered.strip_prefix('.')) + { + return is_name(domain).then(|| ProxyExclusionRule { + kind: ProxyExclusionKind::Suffix, + value: domain.to_string(), + }); + } + is_name(&lowered).then_some(ProxyExclusionRule { + kind: ProxyExclusionKind::Host, + value: lowered, + }) +} + +/// `value` trimmed, lowercase, without one trailing dot. +fn normalized_name(value: &str) -> String { + let lowered = value.trim().to_lowercase(); + match lowered.strip_suffix('.') { + Some(stripped) => stripped.to_string(), + None => lowered, + } +} + +/// Whether `value` can be a host name: not empty, and without wildcards, separators, ports +/// or white space. Labels are not checked further, so internal names stay usable. +fn is_name(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('.') + && !value.contains("..") + && !value + .chars() + .any(|c| c.is_whitespace() || matches!(c, '*' | '/' | ':' | '[' | ']' | '<' | '>')) +} + +/// An address block written `address/prefix`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Block { + network: IpAddr, + prefix: u8, +} + +impl Block { + fn contains(self, address: IpAddr) -> bool { + match (self.network, address) { + (IpAddr::V4(network), IpAddr::V4(address)) => { + masked(u128::from(u32::from(address)), self.prefix, 32) + == u128::from(u32::from(network)) + } + (IpAddr::V6(network), IpAddr::V6(address)) => { + masked(u128::from(address), self.prefix, 128) == u128::from(network) + } + _ => false, + } + } +} + +impl std::fmt::Display for Block { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}/{}", self.network, self.prefix) + } +} + +/// The block `value` writes, with the host bits of its address cleared; `None` when the +/// address or the prefix length is not valid for the address family. +fn block(value: &str) -> Option { + let (address, prefix) = value.split_once('/')?; + let address = address.parse::().ok()?; + if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let prefix: u8 = prefix.parse().ok()?; + let network = match address { + IpAddr::V4(address) => { + if prefix > 32 { + return None; + } + let bits = masked(u128::from(u32::from(address)), prefix, 32); + IpAddr::V4(u32::try_from(bits).ok()?.into()) + } + IpAddr::V6(address) => { + if prefix > 128 { + return None; + } + IpAddr::V6(masked(u128::from(address), prefix, 128).into()) + } + }; + Some(Block { network, prefix }) +} + +/// `bits`, an address of `width` bits, with every bit after the first `prefix` cleared. +fn masked(bits: u128, prefix: u8, width: u8) -> u128 { + if prefix == 0 { + return 0; + } + let keep = u128::MAX << (u32::from(width) - u32::from(prefix)); + let width_mask = if width == 128 { + u128::MAX + } else { + (1u128 << width) - 1 + }; + bits & keep & width_mask +} diff --git a/rust/crates/network/src/lib.rs b/rust/crates/network/src/lib.rs index 8f350d55..b995b227 100644 --- a/rust/crates/network/src/lib.rs +++ b/rust/crates/network/src/lib.rs @@ -4,10 +4,13 @@ //! normalized and validated here so both hosts interpret the same stored //! values identically. Platforms only map the returned policy onto their own //! network stack: `connectionProxyDictionary` plus an authentication delegate -//! on macOS, and the HTTP client configuration on Windows. +//! on macOS, and the HTTP client configuration on Windows. What an exclusion +//! entry means, and which hosts it sends direct, is decided in `exclusions`. +mod exclusions; mod proxy; +pub use exclusions::{bypasses, ProxyExclusionKind, ProxyExclusionRule}; pub use proxy::{ proxy_policy, NetworkProxyIdentity, NetworkProxyMode, NetworkProxyPolicy, NetworkProxyPolicyKind, NetworkProxyPolicyRequest, NetworkProxyType, diff --git a/rust/crates/network/src/proxy.rs b/rust/crates/network/src/proxy.rs index dcf3aeea..e0b9ff47 100644 --- a/rust/crates/network/src/proxy.rs +++ b/rust/crates/network/src/proxy.rs @@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize}; +use crate::exclusions::{parse, ProxyExclusionRule}; + /// How the application's own HTTP traffic reaches the internet. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] @@ -111,6 +113,10 @@ pub struct NetworkProxyPolicy { pub password: String, /// Trimmed, non-empty bypass hosts; empty unless `policy` is custom. pub exclusions: Vec, + /// `exclusions` read into the rules a network stack compares (`bypasses`). + pub exclusion_rules: Vec, + /// Entries of `exclusions` that have none of the supported forms and send no host direct. + pub invalid_exclusions: Vec, /// True when the caller-selected mode could actually be honored; false /// for an incomplete custom server that normalized to the system policy. pub configured: bool, @@ -133,6 +139,8 @@ pub fn proxy_policy(request: NetworkProxyPolicyRequest) -> NetworkProxyPolicy { username: String::new(), password: String::new(), exclusions: Vec::new(), + exclusion_rules: Vec::new(), + invalid_exclusions: Vec::new(), configured: false, }; match request.mode { @@ -154,6 +162,7 @@ pub fn proxy_policy(request: NetworkProxyPolicyRequest) -> NetworkProxyPolicy { && identity.port == request.port && identity.username.trim() == username }); + let parsed = parse(&exclusions); NetworkProxyPolicy { policy: NetworkProxyPolicyKind::Custom, proxy_type, @@ -166,6 +175,8 @@ pub fn proxy_policy(request: NetworkProxyPolicyRequest) -> NetworkProxyPolicy { String::new() }, exclusions, + exclusion_rules: parsed.rules, + invalid_exclusions: parsed.invalid, configured: true, } } diff --git a/rust/crates/network/src/tests.rs b/rust/crates/network/src/tests.rs index 502ac448..284e741b 100644 --- a/rust/crates/network/src/tests.rs +++ b/rust/crates/network/src/tests.rs @@ -2,7 +2,7 @@ use serde_json::{json, Value}; -use crate::{proxy_policy, NetworkProxyPolicyRequest}; +use crate::{bypasses, proxy_policy, NetworkProxyPolicyRequest}; fn policy_for(request: Value) -> Value { let request: NetworkProxyPolicyRequest = @@ -84,3 +84,40 @@ fn saved_password_cannot_be_rebound_by_changing_the_endpoint_or_account() { // A new explicit password belongs to the candidate the user entered. assert_eq!(policy_for(request.clone())["password"], request["password"]); } + +/// Every case of the bypass fixture: the rules and invalid entries a custom policy carries, +/// and which hosts they send direct. Hosts replay the same file with their comparison. +#[test] +fn bypass_fixture_cases_match_the_shared_contract() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/network/proxy-bypass-v1.json" + ))) + .expect("proxy bypass fixture is JSON"); + for case in fixture["cases"].as_array().expect("fixture cases") { + let name = &case["name"]; + let request: NetworkProxyPolicyRequest = serde_json::from_value(json!({ + "mode": "custom", "host": "proxy.example", "port": 3128, + "exclusions": case["exclusions"], + })) + .expect("policy request is valid"); + let policy = proxy_policy(request); + assert_eq!( + serde_json::to_value(&policy.exclusion_rules).expect("rules encode"), + case["exclusionRules"], + "rules of {name}" + ); + assert_eq!( + json!(policy.invalid_exclusions), + case["invalidExclusions"], + "invalid entries of {name}" + ); + for (host, direct) in case["hosts"].as_object().expect("hosts") { + assert_eq!( + bypasses(host, &policy.exclusion_rules), + direct.as_bool().expect("bypass flag"), + "{name}: {host}" + ); + } + } +} diff --git a/rust/crates/plugin-lifecycle/Cargo.toml b/rust/crates/plugin-lifecycle/Cargo.toml new file mode 100644 index 00000000..83bb329d --- /dev/null +++ b/rust/crates/plugin-lifecycle/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-plugin-lifecycle" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Enablement of installed Lithe plugins and the ordered steps of confirming an install, enabling, disabling and uninstalling one" + +[dependencies] +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/crates/plugin-lifecycle/src/lib.rs b/rust/crates/plugin-lifecycle/src/lib.rs new file mode 100644 index 00000000..36106b6a --- /dev/null +++ b/rust/crates/plugin-lifecycle/src/lib.rs @@ -0,0 +1,247 @@ +//! Which installed plugins are enabled, and what a host does, in which order, when the user +//! confirms an installation, enables, disables or uninstalls a plugin. +//! +//! The host keeps the packages, their approval records, the user's enablement choices and the +//! plugins' programs; it hands their identities to `plugins.installed.lifecycle` and carries +//! out the steps it gets back. This crate reads no file and stops nothing. +//! +//! Enablement is per plugin, not per version, and a plugin the user made no choice for is +//! disabled. Confirming an installation is the user saying they want the plugin, so it records +//! the plugin as enabled unless the user already chose: a plugin disabled before an update +//! stays disabled. Disabling records the choice before it stops the plugin's programs, so +//! nothing starts the plugin again while it stops. Uninstalling stops the programs first, since +//! one still running could write its state again and Windows cannot move the directory of a +//! running program; then it removes every installed version, because the plugin page shows one +//! row per plugin and an older version left behind would keep the plugin installed; then it +//! forgets the choice and removes the state the programs wrote, so a later installation starts +//! as a first one. Fixtures live in `rust/fixtures/plugins/installed-lifecycle.json`. + +use std::collections::{BTreeMap, BTreeSet}; + +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Upper bound of installed versions and of choices in one request. +const MAX_ENTRIES: usize = 4096; + +/// Rejection of an action on a plugin that has no installed version: enabling it would +/// silently enable a later installation, and there is nothing to uninstall. +pub const NOT_INSTALLED: &str = "plugin.lifecycle.notInstalled"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + /// Every installed plugin version, in any order. + installed: Vec, + /// Every enablement choice the user made, one per plugin. + #[serde(default)] + choices: Vec, + /// What the user does; without it the response only reports enablement. + #[serde(default)] + action: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct InstalledVersion { + #[serde(rename = "pluginID")] + plugin_id: String, + version: String, +} + +/// One plugin and whether it is enabled: a choice in the request, a plugin's enablement in the +/// response. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Choice { + #[serde(rename = "pluginID")] + plugin_id: String, + enabled: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Action { + kind: ActionKind, + #[serde(rename = "pluginID")] + plugin_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum ActionKind { + /// The user confirmed installing a version of the plugin, which is now on disk. + ConfirmInstall, + /// The user turned the plugin on. + Enable, + /// The user turned the plugin off. + Disable, + /// The user removed the plugin. + Uninstall, +} + +/// One thing the host does for the action, on the action's plugin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum Step { + /// Record the user's choice for the plugin. + RecordChoice { enabled: bool }, + /// Drop the user's choice for the plugin. + ForgetChoice, + /// Stop the plugin's language server and backend, returning once they ended or gave up. + StopPrograms, + /// Remove one installed version, then forget the approval recorded for it; the approval + /// stays when the version could not be removed. + RemoveVersion { version: String }, + /// Remove the state directory the plugin's programs wrote. + RemoveState, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + /// Every installed plugin once, by ID, with its enablement once the steps ran; an + /// uninstalled plugin is left out. + plugins: Vec, + /// Whether the steps change the user's choice for the action's plugin. + changed: bool, + /// What the host does, in order. + steps: Vec, + /// Why the action does not apply; it then has no steps and `plugins` is as before. + #[serde(skip_serializing_if = "Option::is_none")] + rejection: Option<&'static str>, +} + +/// Handles `plugins.installed.lifecycle`. +/// +/// Installed versions and choices are identities only; a repeated version or a second choice +/// for one plugin is an invalid request, as is an empty plugin ID or version. A choice for a +/// plugin that is not installed is kept and reported nowhere: it is what a later installation +/// of the plugin finds. +pub fn command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid plugin lifecycle request", + ) + .with_details(error.to_string()) + })?; + if request.installed.len() > MAX_ENTRIES || request.choices.len() > MAX_ENTRIES { + return Err(invalid("Too many plugins for one lifecycle request")); + } + let mut versions: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for entry in &request.installed { + if entry.plugin_id.is_empty() || entry.version.is_empty() { + return Err(invalid("An installed plugin has an empty ID or version")); + } + if !versions + .entry(&entry.plugin_id) + .or_default() + .insert(&entry.version) + { + return Err(invalid("An installed plugin version is listed twice")); + } + } + let mut choices: BTreeMap<&str, bool> = BTreeMap::new(); + for choice in &request.choices { + if choice.plugin_id.is_empty() { + return Err(invalid("A plugin choice has an empty ID")); + } + if choices.insert(&choice.plugin_id, choice.enabled).is_some() { + return Err(invalid("A plugin has two choices")); + } + } + let Some(action) = &request.action else { + return Ok(encode(Response { + plugins: enablement(&versions, &choices, None), + changed: false, + steps: Vec::new(), + rejection: None, + })); + }; + if action.plugin_id.is_empty() { + return Err(invalid( + "The plugin lifecycle action has an empty plugin ID", + )); + } + let id = action.plugin_id.as_str(); + let Some(installed) = versions.get(id) else { + return Ok(encode(Response { + plugins: enablement(&versions, &choices, None), + changed: false, + steps: Vec::new(), + rejection: Some(NOT_INSTALLED), + })); + }; + let chosen = choices.get(id).copied(); + let mut steps = Vec::new(); + let mut uninstalled = None; + let after = match action.kind { + ActionKind::ConfirmInstall => match chosen { + Some(enabled) => Some(enabled), + None => { + steps.push(Step::RecordChoice { enabled: true }); + Some(true) + } + }, + ActionKind::Enable => { + if chosen != Some(true) { + steps.push(Step::RecordChoice { enabled: true }); + } + Some(true) + } + ActionKind::Disable => { + if chosen != Some(false) { + steps.push(Step::RecordChoice { enabled: false }); + } + steps.push(Step::StopPrograms); + Some(false) + } + ActionKind::Uninstall => { + steps.push(Step::StopPrograms); + steps.extend(installed.iter().map(|version| Step::RemoveVersion { + version: (*version).to_string(), + })); + if chosen.is_some() { + steps.push(Step::ForgetChoice); + } + steps.push(Step::RemoveState); + uninstalled = Some(id); + None + } + }; + if let Some(enabled) = after { + choices.insert(id, enabled); + } + Ok(encode(Response { + plugins: enablement(&versions, &choices, uninstalled), + changed: after != chosen, + steps, + rejection: None, + })) +} + +/// Every installed plugin but `left_out`, by ID, enabled only when its choice says so. +fn enablement( + versions: &BTreeMap<&str, BTreeSet<&str>>, + choices: &BTreeMap<&str, bool>, + left_out: Option<&str>, +) -> Vec { + versions + .keys() + .filter(|id| Some(**id) != left_out) + .map(|id| Choice { + plugin_id: (*id).to_string(), + enabled: choices.get(id).copied().unwrap_or(false), + }) + .collect() +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn encode(response: Response) -> Value { + serde_json::to_value(response).expect("plugin lifecycle response encodes") +} diff --git a/rust/crates/process/src/lifecycle.rs b/rust/crates/process/src/lifecycle.rs index b94245db..b36e2ec2 100644 --- a/rust/crates/process/src/lifecycle.rs +++ b/rust/crates/process/src/lifecycle.rs @@ -16,6 +16,9 @@ use serde::{Deserialize, Serialize}; /// Lower and upper bound of the configurable graceful-stop wait, in seconds. const GRACEFUL_STOP_SECONDS: (u32, u32) = (1, 60); +/// Graceful-stop wait of settings that do not set one, in seconds. +const DEFAULT_GRACEFUL_STOP_SECONDS: u32 = 5; + /// Situation in which the host asks for decisions. #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -219,7 +222,7 @@ impl Default for TerminationSettings { run_services_on_exit: RunServicePolicy::default(), remembered_choices: BTreeMap::new(), startup_sweep: StartupSweepPolicy::default(), - graceful_stop_seconds: 5, + graceful_stop_seconds: DEFAULT_GRACEFUL_STOP_SECONDS, } } } @@ -288,12 +291,34 @@ pub struct LifecycleDecision { pub reason: &'static str, } +/// Rule of `TerminationSettings::graceful_stop_seconds`, so a settings page can show the +/// default and keep the value within the limits `resolve` applies. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GracefulStopRule { + /// Wait of settings that do not set one. + pub default_seconds: u32, + /// Shortest wait; a smaller setting is raised to it. + pub min_seconds: u32, + /// Longest wait; a larger setting is lowered to it. + pub max_seconds: u32, +} + +/// The rule `resolve` applies to the graceful-stop wait. +pub const GRACEFUL_STOP_RULE: GracefulStopRule = GracefulStopRule { + default_seconds: DEFAULT_GRACEFUL_STOP_SECONDS, + min_seconds: GRACEFUL_STOP_SECONDS.0, + max_seconds: GRACEFUL_STOP_SECONDS.1, +}; + /// Response of `processLifecycle.resolve`. #[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct LifecyclePlan { /// Wait between the graceful stop request and the forced kill. pub graceful_stop_milliseconds: u64, + /// Default and limits of the setting that produced `graceful_stop_milliseconds`. + pub graceful_stop_rule: GracefulStopRule, /// One decision per record, in request order. pub decisions: Vec, } @@ -307,6 +332,7 @@ pub fn resolve(request: &LifecycleRequest) -> LifecyclePlan { .clamp(minimum, maximum); LifecyclePlan { graceful_stop_milliseconds: u64::from(seconds) * 1_000, + graceful_stop_rule: GRACEFUL_STOP_RULE, decisions: request .records .iter() @@ -685,4 +711,28 @@ mod tests { 60_000 ); } + + #[test] + fn graceful_stop_rule_is_what_resolve_applies() { + let resolved = |seconds: Option| { + let settings = match seconds { + Some(graceful_stop_seconds) => TerminationSettings { + graceful_stop_seconds, + ..TerminationSettings::default() + }, + None => TerminationSettings::default(), + }; + let plan = resolve(&LifecycleRequest { + context: LifecycleContext::Quit, + settings, + records: Vec::new(), + }); + assert_eq!(plan.graceful_stop_rule, GRACEFUL_STOP_RULE); + plan.graceful_stop_milliseconds / 1_000 + }; + let rule = GRACEFUL_STOP_RULE; + assert_eq!(resolved(None), u64::from(rule.default_seconds)); + assert_eq!(resolved(Some(0)), u64::from(rule.min_seconds)); + assert_eq!(resolved(Some(u32::MAX)), u64::from(rule.max_seconds)); + } } diff --git a/rust/crates/project-tree/Cargo.toml b/rust/crates/project-tree/Cargo.toml new file mode 100644 index 00000000..64494d84 --- /dev/null +++ b/rust/crates/project-tree/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-project-tree" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "The rows the project tree shows from the nodes a host listed, with its selection kept in step" + +[dependencies] +lithe-protocol = { path = "../protocol" } +lithe-row-selection = { path = "../row-selection" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/crates/project-tree/src/lib.rs b/rust/crates/project-tree/src/lib.rs new file mode 100644 index 00000000..ce339616 --- /dev/null +++ b/rust/crates/project-tree/src/lib.rs @@ -0,0 +1,193 @@ +//! The rows the project tree shows, and its selection kept in step with them. +//! +//! The host lists folders with `workspace.listDirectories` and keeps the nodes it listed, which +//! folders are open, focus, scrolling and painting. When the shape of the tree changes (a +//! folder opens or closes, a listing arrives, a node goes away) it sends +//! `workspace.projectTree.rows` the loaded nodes in preorder with their depth and whether each +//! folder is open, the selection, and the path of the row at the top of the view. Core answers +//! with the rows to show, the selection repaired, and the row the top one is now; the host asks +//! once per change, never per paint, and nothing here reads the disk. A folder's children are +//! only what the host listed, so opening a folder never enumerates what is under it. +//! +//! A row shows when every folder above it is open; the root, the project folder, always shows. +//! A selected node that went away gives the selection to the nearest folder above it that is +//! still loaded, and a selected node a closed folder hides gives it to the nearest row above it +//! that shows, so the selection is always on a row the user sees. Marks and an anchor whose +//! node went away are dropped; marks inside a closed folder stay. The selection itself follows +//! `lithe-row-selection`. Fixtures live in `rust/fixtures/workspace/project-tree-rows-v1.json`. + +use std::collections::HashMap; + +use lithe_protocol::{CoreError, ErrorCode}; +use lithe_row_selection::{ancestors, targets, Selection}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Upper bound of nodes in one request. +const MAX_NODES: usize = 1 << 20; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + /// The loaded nodes in preorder; the first is the root. + nodes: Vec, + #[serde(default)] + selection: Selection, + /// The path of the row at the top of the view, which should stay there. + #[serde(default)] + top: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Node { + /// Workspace-relative with `/` separators; empty for the root. + path: String, + /// 0 for the root, and one more than the folder the node is in. + depth: usize, + /// The node is a folder the user opened. + #[serde(default)] + open: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + /// The positions in `nodes` of the rows to show, top to bottom. + rows: Vec, + selection: Selection, + /// The selected row is not the one the request selected, because that node went away or + /// a closed folder hides it. + selection_moved: bool, + /// The row `top` names, while it shows. + top_row: Option, + /// What a batch acts on, as `lithe-row-selection` says. + targets: Vec, +} + +/// Handles `workspace.projectTree.rows`. +pub fn command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid project tree request") + .with_details(error.to_string()) + })?; + request.selection.validate()?; + let nodes = &request.nodes; + if nodes.len() > MAX_NODES { + return Err(invalid("Too many project tree nodes for one request")); + } + match nodes.first() { + Some(root) if root.path.is_empty() && root.depth == 0 => {} + _ => return Err(invalid("The first project tree node must be the root")), + } + let mut position = HashMap::with_capacity(nodes.len()); + let mut shown = vec![false; nodes.len()]; + let mut rows = Vec::new(); + // `folders[d]` is the node at depth `d` above the current one, and `closed` the depth of + // the shallowest folder above it that is closed. + let mut folders: Vec = Vec::new(); + let mut closed: Option = None; + for (index, node) in nodes.iter().enumerate() { + if index > 0 { + if node.depth == 0 || node.depth > folders.len() { + return Err(invalid("A project tree node skips a depth")); + } + folders.truncate(node.depth); + let parent = &nodes[folders[node.depth - 1]].path; + if !is_child(parent, &node.path) { + return Err(invalid("A project tree node is not in the folder above it")); + } + } + if position.insert(node.path.as_str(), index).is_some() { + return Err(invalid("A project tree path is listed twice")); + } + if closed.is_some_and(|depth| node.depth <= depth) { + closed = None; + } + if closed.is_none() { + shown[index] = true; + rows.push(index); + if !node.open { + closed = Some(node.depth); + } + } + folders.push(index); + } + + let loaded = |path: &str| position.contains_key(path); + let shows = |path: &str| position.get(path).is_some_and(|&index| shown[index]); + let mut selection = request.selection; + let before = selection.selected.clone(); + if let Some(path) = &selection.selected { + if !shows(path) { + // The root always shows, so there is always one. + let nearest = ancestors(path) + .find(|above| shows(above)) + .unwrap_or_default(); + selection.selected = Some(nearest.to_string()); + } + } + selection.marked.retain(|path| loaded(path)); + if selection + .anchor + .as_deref() + .is_some_and(|path| !loaded(path)) + { + selection.anchor = None; + } + let row_of: HashMap = rows + .iter() + .enumerate() + .map(|(row, &node)| (node, row)) + .collect(); + let top_row = request + .top + .as_deref() + .and_then(|path| position.get(path)) + .and_then(|node| row_of.get(node).copied()); + let targets = targets(&selection); + let selection_moved = selection.selected != before; + Ok(serde_json::to_value(Response { + rows, + selection, + selection_moved, + top_row, + targets, + }) + .expect("project tree response encodes")) +} + +/// Whether `path` names an entry directly inside the folder at `parent`. +fn is_child(parent: &str, path: &str) -> bool { + let name = if parent.is_empty() { + path + } else { + match path + .strip_prefix(parent) + .and_then(|rest| rest.strip_prefix('/')) + { + Some(name) => name, + None => return false, + } + }; + !name.is_empty() && !name.contains('/') +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_child_is_one_name_below_its_folder() { + assert!(is_child("", "a")); + assert!(is_child("a", "a/b")); + assert!(!is_child("a", "ab/c")); + assert!(!is_child("a", "a/b/c")); + assert!(!is_child("", "a/b")); + assert!(!is_child("a", "a/")); + } +} diff --git a/rust/crates/row-selection/Cargo.toml b/rust/crates/row-selection/Cargo.toml new file mode 100644 index 00000000..a2575faf --- /dev/null +++ b/rust/crates/row-selection/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-row-selection" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "The multi-selection of rows of workspace paths: the selected row, the rows marked for a batch and the row a Shift click extends from" + +[dependencies] +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/crates/row-selection/src/lib.rs b/rust/crates/row-selection/src/lib.rs new file mode 100644 index 00000000..ff799f59 --- /dev/null +++ b/rust/crates/row-selection/src/lib.rs @@ -0,0 +1,220 @@ +//! The multi-selection of rows that show workspace paths, such as the project tree and the +//! changes of a change list. +//! +//! A selection has three parts: the selected row, which the keyboard moves and a plain click +//! sets; the rows a Cmd or Ctrl click or a Shift click marked for a batch; and the anchor, the +//! row a Shift click extends from. A Cmd or Ctrl click marks or unmarks one row and makes it +//! the anchor. A Shift click marks every row from the anchor, or from the selected row when +//! there is no anchor, to the clicked one, and leaves the marks it does not cover. A plain +//! click selects its row and drops the marks and the anchor. The empty path is the workspace +//! root, which is never marked: a batch on the whole workspace is never what a click meant. +//! +//! The host keeps focus, painting and what a batch does; it sends the paths of the rows it +//! shows, top to bottom, with the selection and one action, and keeps the selection that comes +//! back. Marks of rows the host does not show, such as rows inside a closed folder, stay. +//! [`targets`] is what a batch acts on. Fixtures live in +//! `rust/fixtures/workspace/row-selection-v1.json`. + +use std::collections::BTreeSet; + +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Upper bound of rows and of marks in one request. +pub const MAX_ROWS: usize = 1 << 20; + +/// Rejection of an action on a path that is not one of the rows, as when the row went away +/// between the paint that showed it and the click. +pub const STALE: &str = "workspace.rowSelection.stale"; + +/// The selected row, the marked rows and the anchor, by path. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Selection { + /// The path of the selected row; the empty path is the workspace root. + #[serde(default)] + pub selected: Option, + /// The paths marked for a batch, in path order; never the empty path. + #[serde(default)] + pub marked: BTreeSet, + /// The path a Shift click extends from; never the empty path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor: Option, +} + +impl Selection { + /// Rejects marks and an anchor on the workspace root, and more marks than [`MAX_ROWS`]. + pub fn validate(&self) -> Result<(), CoreError> { + if self.marked.len() > MAX_ROWS { + return Err(invalid("Too many marked rows for one request")); + } + if self.marked.contains("") { + return Err(invalid("The workspace root cannot be marked")); + } + if self.anchor.as_deref() == Some("") { + return Err(invalid("The workspace root cannot be the anchor")); + } + Ok(()) + } +} + +/// What a batch acts on: the marked paths in path order, without those inside a marked folder, +/// since the folder stands for its contents. It is empty when nothing is marked; a host then +/// acts on the selected row alone. +pub fn targets(selection: &Selection) -> Vec { + selection + .marked + .iter() + .filter(|path| !ancestors(path).any(|above| selection.marked.contains(above))) + .cloned() + .collect() +} + +/// The paths above `path`, nearest first, ending with the workspace root. +pub fn ancestors(path: &str) -> impl Iterator { + let mut rest = (!path.is_empty()).then_some(path); + std::iter::from_fn(move || { + let current = rest?; + let above = current.rfind('/').map_or("", |slash| ¤t[..slash]); + rest = (!above.is_empty()).then_some(above); + Some(above) + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + /// The paths of the rows the host shows, top to bottom. + rows: Vec, + #[serde(default)] + selection: Selection, + action: Action, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum Action { + /// A plain click on the row of `path`. + Choose { path: String }, + /// A Cmd or Ctrl click on the row of `path`. + Toggle { path: String }, + /// A Shift click on the row of `path`. + Extend { path: String }, + /// The batch is over: the marks and the anchor go and the selected row stays. + Clear, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + selection: Selection, + /// What a batch acts on, as [`targets`] says. + targets: Vec, + /// Why the action did not apply; the selection is then as sent. + #[serde(skip_serializing_if = "Option::is_none")] + rejection: Option<&'static str>, +} + +/// Handles `workspace.rowSelection.reduce`. +pub fn command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid row selection request") + .with_details(error.to_string()) + })?; + if request.rows.len() > MAX_ROWS { + return Err(invalid("Too many rows for one request")); + } + request.selection.validate()?; + let mut selection = request.selection; + let rejection = apply(&request.rows, &mut selection, request.action); + let targets = targets(&selection); + Ok(serde_json::to_value(Response { + selection, + targets, + rejection, + }) + .expect("row selection response encodes")) +} + +/// Applies `action` to `selection` over `rows`, or says why it does not apply. +fn apply(rows: &[String], selection: &mut Selection, action: Action) -> Option<&'static str> { + let row = |path: &str| rows.iter().position(|row| row == path); + match action { + Action::Clear => { + selection.marked.clear(); + selection.anchor = None; + } + Action::Choose { path } => { + if row(&path).is_none() { + return Some(STALE); + } + selection.marked.clear(); + selection.anchor = None; + selection.selected = Some(path); + } + Action::Toggle { path } => { + if row(&path).is_none() { + return Some(STALE); + } + if path.is_empty() { + return None; + } + if !selection.marked.remove(&path) { + selection.marked.insert(path.clone()); + } + selection.anchor = Some(path.clone()); + selection.selected = Some(path); + } + Action::Extend { path } => { + let Some(at) = row(&path) else { + return Some(STALE); + }; + if path.is_empty() { + return None; + } + let from = [selection.anchor.as_deref(), selection.selected.as_deref()] + .into_iter() + .flatten() + .find_map(row) + .unwrap_or(at); + let (low, high) = (from.min(at), from.max(at)); + selection.marked.extend( + rows[low..=high] + .iter() + .filter(|row| !row.is_empty()) + .cloned(), + ); + selection.selected = Some(path); + } + } + None +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ancestors_end_with_the_root() { + assert_eq!(ancestors("a/b/c").collect::>(), ["a/b", "a", ""]); + assert_eq!(ancestors("a").collect::>(), [""]); + assert_eq!(ancestors("").count(), 0); + } + + #[test] + fn a_marked_folder_stands_for_its_contents() { + let selection = Selection { + marked: ["a", "a-b", "a/b", "c/d"] + .into_iter() + .map(str::to_string) + .collect(), + ..Selection::default() + }; + assert_eq!(targets(&selection), ["a", "a-b", "c/d"]); + } +} diff --git a/rust/fixtures/editor/group-layout-v1.json b/rust/fixtures/editor/group-layout-v1.json new file mode 100644 index 00000000..23ecb369 --- /dev/null +++ b/rust/fixtures/editor/group-layout-v1.json @@ -0,0 +1,1724 @@ +{ + "description": "editor.groupLayout.reduce and editor.groupLayout.dropTargets: one transition of the editor groups together with the tree that divides the editor area among them, and the places of each group a dragged tab may be dropped on. Hosts keep splitter ratios, pixels and the drag itself.", + "reduce": { + "cases": [ + { + "name": "a workbench action that removes a group takes its leaf out of the layout", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + }, + { + "id": "editor:2", + "members": [ + "b" + ], + "selected": "b" + } + ] + }, + "action": { + "kind": "close", + "group": "editor:2", + "member": "b", + "emptyGroups": "remove" + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [ + "editor:2" + ] + } + }, + { + "name": "a leaf whose group the state does not have leaves the layout", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "normalize" + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:9" + } + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [ + "a" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [] + } + }, + { + "name": "openTab opens in the preferred group while the layout shows it", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + }, + { + "id": "editor:2", + "members": [ + "b" + ], + "selected": "b" + } + ] + }, + "action": { + "kind": "openTab", + "member": "c", + "preferredGroup": "editor:2", + "newGroup": "editor:3" + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:2", + "members": [ + "b", + "c" + ], + "selected": "c", + "mru": [ + "c", + "b" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + }, + "removedGroupIds": [], + "group": "editor:2" + } + }, + { + "name": "openTab falls back to the first group of the layout", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1" + }, + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "openTab", + "member": "c", + "preferredGroup": "tool", + "newGroup": "editor:2" + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:1", + "members": [ + "a", + "c" + ], + "selected": "c", + "mru": [ + "c", + "a" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "group": "editor:1" + } + }, + { + "name": "openTab without a layout makes a group that becomes the whole layout", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1" + } + ] + }, + "action": { + "kind": "openTab", + "member": "a", + "newGroup": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [ + "a" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "group": "editor:1" + } + }, + { + "name": "dropping a tab in the strip of its own group reorders it", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b", + "c" + ], + "selected": "b" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "join", + "group": "editor:1", + "index": 2 + } + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "b", + "c", + "a" + ], + "selected": "a", + "mru": [ + "a", + "b", + "c" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "group": "editor:1" + } + }, + { + "name": "dropping a tab in another strip moves it before the tab at the index and removes the emptied group", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + }, + { + "id": "editor:2", + "members": [ + "b", + "c" + ], + "selected": "b" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "join", + "group": "editor:2", + "index": 1 + } + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:2", + "members": [ + "b", + "a", + "c" + ], + "selected": "a", + "mru": [ + "a", + "b", + "c" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:2" + }, + "removedGroupIds": [ + "editor:1" + ], + "group": "editor:2" + } + }, + { + "name": "a tab leaving a docked group keeps the group, and an index past the end appends", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1" + }, + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "t1", + "target": { + "kind": "join", + "group": "editor:1", + "index": 9 + }, + "docked": [ + "tool" + ], + "dockable": true + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "tool", + "members": [], + "selected": null, + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:1", + "members": [ + "a", + "t1" + ], + "selected": "t1", + "mru": [ + "t1", + "a" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "group": "editor:1" + } + }, + { + "name": "dropping a tab on a body edge splits the group with the offered group and number", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b" + ], + "selected": "b" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "b", + "target": { + "kind": "split", + "group": "editor:1", + "edge": "right" + }, + "newGroup": "editor:2", + "newNode": 1 + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [ + "a" + ], + "pinned": [], + "preview": null + }, + { + "id": "editor:2", + "members": [ + "b" + ], + "selected": "b", + "mru": [ + "b" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + }, + "removedGroupIds": [], + "group": "editor:2" + } + }, + { + "name": "a leading edge puts the new group first, after the emptied source left the layout", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + }, + { + "id": "editor:2", + "members": [ + "b", + "c" + ], + "selected": "b" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "split", + "group": "editor:2", + "edge": "top" + }, + "newGroup": "editor:3", + "newNode": 2 + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:2", + "members": [ + "b", + "c" + ], + "selected": "b", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:3", + "members": [ + "a" + ], + "selected": "a", + "mru": [ + "a" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "node": 2, + "row": false, + "first": { + "group": "editor:3" + }, + "second": { + "group": "editor:2" + } + }, + "removedGroupIds": [ + "editor:1" + ], + "group": "editor:3" + } + }, + { + "name": "dropping a terminal on an editor area without groups opens its first group", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1", + "t2" + ], + "selected": "t2" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "t2", + "target": { + "kind": "empty" + }, + "docked": [ + "tool" + ], + "dockable": true, + "newGroup": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1", + "mru": [ + "t1" + ], + "pinned": [], + "preview": null + }, + { + "id": "editor:1", + "members": [ + "t2" + ], + "selected": "t2", + "mru": [ + "t2" + ], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "group": "editor:1" + } + }, + { + "name": "a drop of a tab no group has is stale", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "x", + "target": { + "kind": "join", + "group": "editor:1", + "index": 0 + } + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "rejection": "editor.groupLayout.stale" + } + }, + { + "name": "a drop on a group that went away is stale", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "join", + "group": "editor:9", + "index": 0 + } + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "rejection": "editor.groupLayout.stale" + } + }, + { + "name": "a split of a group the layout does not show is stale", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1", + "t2" + ], + "selected": "t1" + }, + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "t1", + "target": { + "kind": "split", + "group": "tool", + "edge": "left" + }, + "docked": [ + "tool" + ], + "dockable": true, + "newGroup": "editor:2", + "newNode": 1 + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1", + "t2" + ], + "selected": "t1", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "rejection": "editor.groupLayout.stale" + } + }, + { + "name": "a drop on an empty editor area while the layout shows a group is stale", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1" + }, + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "t1", + "target": { + "kind": "empty" + }, + "docked": [ + "tool" + ], + "dockable": true, + "newGroup": "editor:2" + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "rejection": "editor.groupLayout.stale" + } + }, + { + "name": "the only tab of a group dropped back on it is refused", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a" + }, + { + "id": "editor:2", + "members": [ + "b" + ], + "selected": "b" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "split", + "group": "editor:1", + "edge": "left" + }, + "newGroup": "editor:3", + "newNode": 2 + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:2", + "members": [ + "b" + ], + "selected": "b", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + }, + "removedGroupIds": [], + "rejection": "editor.groupLayout.refused" + } + }, + { + "name": "a tab that is not dockable joining a docked group is refused", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1" + }, + { + "id": "editor:1", + "members": [ + "a", + "b" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "join", + "group": "tool", + "index": 0 + }, + "docked": [ + "tool" + ] + }, + "layout": { + "group": "editor:1" + } + }, + "response": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ], + "selected": "t1", + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:1", + "members": [ + "a", + "b" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "group": "editor:1" + }, + "removedGroupIds": [], + "rejection": "editor.groupLayout.refused" + } + }, + { + "name": "removeEmptyGroups removes only the named groups that have no tab", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [] + }, + { + "id": "editor:2", + "members": [] + }, + { + "id": "editor:3", + "members": [ + "a" + ], + "selected": "a" + } + ] + }, + "action": { + "kind": "removeEmptyGroups", + "groups": [ + "editor:1", + "editor:3" + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "node": 2, + "row": false, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + }, + "second": { + "group": "editor:3" + } + } + }, + "response": { + "state": { + "groups": [ + { + "id": "editor:2", + "members": [], + "selected": null, + "mru": [], + "pinned": [], + "preview": null + }, + { + "id": "editor:3", + "members": [ + "a" + ], + "selected": "a", + "mru": [], + "pinned": [], + "preview": null + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:2" + }, + "second": { + "group": "editor:3" + } + }, + "removedGroupIds": [ + "editor:1" + ] + } + } + ], + "invalid": [ + { + "name": "a repeated group ID", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ] + }, + { + "id": "editor:1", + "members": [ + "b" + ] + } + ] + }, + "action": { + "kind": "normalize" + } + } + }, + { + "name": "a layout leaf without a group", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ] + } + ] + }, + "layout": { + "group": "" + }, + "action": { + "kind": "normalize" + } + } + }, + { + "name": "a group in the layout twice", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ] + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:1" + } + }, + "action": { + "kind": "normalize" + } + } + }, + { + "name": "a split number in the layout twice", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ] + }, + { + "id": "editor:2", + "members": [ + "b" + ] + }, + { + "id": "editor:3", + "members": [ + "c" + ] + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "node": 1, + "row": false, + "first": { + "group": "editor:2" + }, + "second": { + "group": "editor:3" + } + } + }, + "action": { + "kind": "normalize" + } + } + }, + { + "name": "an unknown field of a drop", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b" + ] + } + ] + }, + "layout": { + "group": "editor:1" + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "empty" + }, + "pixels": 3 + } + } + }, + { + "name": "a split without a number", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b" + ] + } + ] + }, + "layout": { + "group": "editor:1" + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "split", + "group": "editor:1", + "edge": "left" + }, + "newGroup": "editor:2" + } + } + }, + { + "name": "a split with a number in use", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b" + ] + }, + { + "id": "editor:2", + "members": [ + "c" + ] + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "split", + "group": "editor:1", + "edge": "left" + }, + "newGroup": "editor:3", + "newNode": 1 + } + } + }, + { + "name": "a split with a group that exists", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a", + "b" + ] + }, + { + "id": "editor:2", + "members": [ + "c" + ] + } + ] + }, + "layout": { + "group": "editor:1" + }, + "action": { + "kind": "dropTab", + "member": "a", + "target": { + "kind": "split", + "group": "editor:1", + "edge": "left" + }, + "newGroup": "editor:2", + "newNode": 1 + } + } + }, + { + "name": "openTab without a layout and without a new group", + "request": { + "state": { + "groups": [] + }, + "action": { + "kind": "openTab", + "member": "a", + "newGroup": "" + } + } + } + ] + }, + "dropTargets": { + "cases": [ + { + "name": "a file tab joins any editor group, splits the ones the layout shows and never enters a docked group", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ] + }, + { + "id": "editor:1", + "members": [ + "a", + "b" + ] + }, + { + "id": "editor:2", + "members": [ + "c" + ] + } + ] + }, + "member": "a", + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:2" + } + }, + "docked": [ + "tool" + ], + "dockable": false + }, + "response": { + "groups": [ + { + "group": "tool", + "join": false, + "split": false, + "append": false + }, + { + "group": "editor:1", + "join": true, + "split": true, + "append": false + }, + { + "group": "editor:2", + "join": true, + "split": true, + "append": true + } + ], + "empty": false + } + }, + { + "name": "a terminal joins its docked group but never splits it", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1", + "t2" + ] + }, + { + "id": "editor:1", + "members": [ + "a" + ] + } + ] + }, + "member": "t1", + "layout": { + "group": "editor:1" + }, + "docked": [ + "tool" + ], + "dockable": true + }, + "response": { + "groups": [ + { + "group": "tool", + "join": true, + "split": false, + "append": false + }, + { + "group": "editor:1", + "join": true, + "split": true, + "append": true + } + ], + "empty": false + } + }, + { + "name": "the only tab of a group has no place in it, and may open an editor area without groups", + "request": { + "state": { + "groups": [ + { + "id": "tool", + "members": [ + "t1" + ] + } + ] + }, + "member": "t1", + "docked": [ + "tool" + ], + "dockable": true + }, + "response": { + "groups": [ + { + "group": "tool", + "join": false, + "split": false, + "append": false + } + ], + "empty": true + } + }, + { + "name": "a tab no group has has no place", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ] + } + ] + }, + "member": "x", + "layout": { + "group": "editor:1" + } + }, + "response": { + "groups": [ + { + "group": "editor:1", + "join": false, + "split": false, + "append": false + } + ], + "empty": false + } + } + ], + "invalid": [ + { + "name": "an unknown field", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ] + } + ] + }, + "member": "a", + "pointer": [ + 1, + 2 + ] + } + }, + { + "name": "a group in the layout twice", + "request": { + "state": { + "groups": [ + { + "id": "editor:1", + "members": [ + "a" + ] + } + ] + }, + "layout": { + "node": 1, + "row": true, + "first": { + "group": "editor:1" + }, + "second": { + "group": "editor:1" + } + }, + "member": "a" + } + } + ] + } +} diff --git a/rust/fixtures/git/changed-file-groups-v1.json b/rust/fixtures/git/changed-file-groups-v1.json new file mode 100644 index 00000000..bb72a328 --- /dev/null +++ b/rust/fixtures/git/changed-file-groups-v1.json @@ -0,0 +1,255 @@ +{ + "version": 1, + "description": "git.changedFileGroups groups repository-relative paths by the folder before their last '/', \"\" being the top of the repository; files are indices into paths. Folder paths compare segment by segment and file names by name, both in the project tree's natural order (workspace/file-name-order-v1.json); raw text, then request order, breaks the remaining ties. Views own expansion, rows and drawing.", + "cases": [ + { + "name": "rootFilesAndNestedFolders", + "request": { + "paths": [ + "b/x.go", + "a.go", + "b/c/y.go", + "b/a.go", + "README.md" + ] + }, + "folders": [ + { + "path": "", + "files": [ + 1, + 4 + ] + }, + { + "path": "b", + "files": [ + 3, + 0 + ] + }, + { + "path": "b/c", + "files": [ + 2 + ] + } + ] + }, + { + "name": "naturalOrderOfFoldersAndFiles", + "request": { + "paths": [ + "docs/v0.1.12.md", + "docs/v0.1.4.md", + "v10/a", + "v2/a", + "docs/TEMPLATE.md" + ] + }, + "folders": [ + { + "path": "docs", + "files": [ + 4, + 1, + 0 + ] + }, + { + "path": "v2", + "files": [ + 3 + ] + }, + { + "path": "v10", + "files": [ + 2 + ] + } + ] + }, + { + "name": "folderRightBeforeItsSubfolders", + "request": { + "paths": [ + "b-x/f", + "b/c/f", + "b/f", + "b.d/f" + ] + }, + "folders": [ + { + "path": "b", + "files": [ + 2 + ] + }, + { + "path": "b/c", + "files": [ + 1 + ] + }, + { + "path": "b.d", + "files": [ + 3 + ] + }, + { + "path": "b-x", + "files": [ + 0 + ] + } + ] + }, + { + "name": "sameNameInDifferentFolders", + "request": { + "paths": [ + "src/main.rs", + "tests/main.rs", + "main.rs" + ] + }, + "folders": [ + { + "path": "", + "files": [ + 2 + ] + }, + { + "path": "src", + "files": [ + 0 + ] + }, + { + "path": "tests", + "files": [ + 1 + ] + } + ] + }, + { + "name": "deepFoldersAndRenamedPaths", + "request": { + "paths": [ + "a/b/c/d/new_name.rs", + "a/b/c/d/e/f.rs", + "a/b/old.rs" + ] + }, + "folders": [ + { + "path": "a/b", + "files": [ + 2 + ] + }, + { + "path": "a/b/c/d", + "files": [ + 0 + ] + }, + { + "path": "a/b/c/d/e", + "files": [ + 1 + ] + } + ] + }, + { + "name": "nonAsciiNames", + "request": { + "paths": [ + "文档/说明.md", + "文档/a.md", + "Ärger/b.txt", + "zeta/c" + ] + }, + "folders": [ + { + "path": "zeta", + "files": [ + 3 + ] + }, + { + "path": "Ärger", + "files": [ + 2 + ] + }, + { + "path": "文档", + "files": [ + 1, + 0 + ] + } + ] + }, + { + "name": "namesDifferingOnlyInCase", + "request": { + "paths": [ + "src/b.txt", + "src/B.txt", + "src/a.txt", + "Src/x" + ] + }, + "folders": [ + { + "path": "Src", + "files": [ + 3 + ] + }, + { + "path": "src", + "files": [ + 2, + 1, + 0 + ] + } + ] + }, + { + "name": "repeatedPathKeepsRequestOrder", + "request": { + "paths": [ + "a/x", + "a/x" + ] + }, + "folders": [ + { + "path": "a", + "files": [ + 0, + 1 + ] + } + ] + }, + { + "name": "noFiles", + "request": { + "paths": [] + }, + "folders": [] + } + ] +} diff --git a/rust/fixtures/git/worktree-creation-preview-v1.json b/rust/fixtures/git/worktree-creation-preview-v1.json new file mode 100644 index 00000000..230fa778 --- /dev/null +++ b/rust/fixtures/git/worktree-creation-preview-v1.json @@ -0,0 +1,893 @@ +{ + "protocolVersion": 1, + "command": "git.worktreeCreationPreview", + "description": "Choices, branch name, suggested destination and readiness of the worktree creation dialog. Paths are written for Unix; the test rewrites them for Windows.", + "cases": [ + { + "name": "new branch opens on the current branch and suggests a destination", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "name": "feature/login-page" + }, + "response": { + "choices": [ + 0, + 1, + 4 + ], + "reference": "refs/heads/main", + "branch": { + "typingCleanedName": "feature/login-page", + "cleanedName": "feature/login-page", + "initialName": "", + "error": null + }, + "suggestedDestination": "/work/app-login-page", + "destination": "/work/app-login-page", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "new branch keeps a typed absolute destination", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "name": "feature/login-page", + "reference": "refs/heads/main", + "destination": "/elsewhere/checkout", + "lastSuggestion": "/work/app-login-page" + }, + "response": { + "choices": [ + 0, + 1, + 4 + ], + "reference": "refs/heads/main", + "branch": { + "typingCleanedName": "feature/login-page", + "cleanedName": "feature/login-page", + "initialName": "", + "error": null + }, + "suggestedDestination": "/work/app-login-page", + "destination": "/elsewhere/checkout", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "destination follows the suggestion until edited", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "name": "feature/signup", + "reference": "refs/heads/main", + "destination": "/work/app-login-page", + "lastSuggestion": "/work/app-login-page" + }, + "response": { + "choices": [ + 0, + 1, + 4 + ], + "reference": "refs/heads/main", + "branch": { + "typingCleanedName": "feature/signup", + "cleanedName": "feature/signup", + "initialName": "", + "error": null + }, + "suggestedDestination": "/work/app-signup", + "destination": "/work/app-signup", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "existing branch name is an error on submit", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "name": "feature/login", + "requireName": true, + "reference": "refs/heads/main" + }, + "response": { + "choices": [ + 0, + 1, + 4 + ], + "reference": "refs/heads/main", + "branch": { + "typingCleanedName": "feature/login", + "cleanedName": "feature/login", + "initialName": "", + "error": { + "code": "alreadyExists", + "name": "feature/login" + } + }, + "suggestedDestination": "/work/app-login", + "destination": "/work/app-login", + "destinationIsAbsolute": true, + "ready": false + } + }, + { + "name": "empty name is named after the start", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "reference": "refs/heads/main" + }, + "response": { + "choices": [ + 0, + 1, + 4 + ], + "reference": "refs/heads/main", + "branch": { + "typingCleanedName": "", + "cleanedName": "", + "initialName": "", + "error": null + }, + "suggestedDestination": "/work/app-main", + "destination": "/work/app-main", + "destinationIsAbsolute": true, + "ready": false + } + }, + { + "name": "empty name is an error on submit", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "requireName": true, + "reference": "refs/heads/main" + }, + "response": { + "choices": [ + 0, + 1, + 4 + ], + "reference": "refs/heads/main", + "branch": { + "typingCleanedName": "", + "cleanedName": "", + "initialName": "", + "error": { + "code": "empty" + } + }, + "suggestedDestination": "/work/app-main", + "destination": "/work/app-main", + "destinationIsAbsolute": true, + "ready": false + } + }, + { + "name": "remote kind lists remote branches", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "kind": "remote", + "name": "fix bug#12" + }, + "response": { + "choices": [ + 2 + ], + "reference": "refs/remotes/origin/main", + "branch": { + "typingCleanedName": "fix-bug#12", + "cleanedName": "fix-bug#12", + "initialName": "", + "error": null + }, + "suggestedDestination": "/work/app-fix-bug-12", + "destination": "/work/app-fix-bug-12", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "existing branch lists free local branches only", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "existingBranch", + "kind": "tag", + "reference": "refs/heads/main" + }, + "response": { + "choices": [ + 1, + 4 + ], + "reference": "refs/heads/feature/login", + "branch": null, + "suggestedDestination": "/work/app-login", + "destination": "/work/app-login", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "existing branch keeps a free choice and replaces other characters", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "existingBranch", + "reference": "refs/heads/用户/修复 bug", + "destination": "", + "lastSuggestion": "" + }, + "response": { + "choices": [ + 1, + 4 + ], + "reference": "refs/heads/用户/修复 bug", + "branch": null, + "suggestedDestination": "/work/app-修复-bug", + "destination": "/work/app-修复-bug", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "detached is named after the first nine characters of the revision", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "detached", + "kind": "tag", + "revision": " 1234567890abcdef " + }, + "response": { + "choices": [ + 3 + ], + "reference": "refs/tags/v1.0", + "branch": null, + "suggestedDestination": "/work/app-123456789", + "destination": "/work/app-123456789", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "detached counts characters, not bytes", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "detached", + "kind": "tag", + "revision": "版本发布候选一号二号三" + }, + "response": { + "choices": [ + 3 + ], + "reference": "refs/tags/v1.0", + "branch": null, + "suggestedDestination": "/work/app-版本发布候选一号二", + "destination": "/work/app-版本发布候选一号二", + "destinationIsAbsolute": true, + "ready": true + } + }, + { + "name": "detached without references needs a revision", + "request": { + "root": "/work/app", + "mode": "detached", + "references": [] + }, + "response": { + "choices": [], + "reference": null, + "branch": null, + "suggestedDestination": "/work/app-worktree", + "destination": "/work/app-worktree", + "destinationIsAbsolute": true, + "ready": false + } + }, + { + "name": "relative destination is not ready", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "detached", + "revision": "HEAD~1", + "destination": "../app-HEAD-1", + "lastSuggestion": "/work/app-HEAD-1" + }, + "response": { + "choices": [ + 0, + 1, + 4 + ], + "reference": "refs/heads/main", + "branch": null, + "suggestedDestination": "/work/app-HEAD-1", + "destination": "../app-HEAD-1", + "destinationIsAbsolute": false, + "ready": false + } + } + ], + "invalidRequests": [ + { + "name": "unknown field", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "extra": true + }, + "code": "invalid_request" + }, + { + "name": "unknown mode", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "bare" + }, + "code": "invalid_request" + }, + { + "name": "branch name over 1024 characters", + "request": { + "root": "/work/app", + "references": [ + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": true, + "peelsToCommit": true + }, + { + "fullName": "refs/heads/feature/login", + "shortName": "feature/login", + "kind": "local", + "isCurrent": false + }, + { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote", + "isCurrent": false + }, + { + "fullName": "refs/tags/v1.0", + "shortName": "v1.0", + "kind": "tag", + "isCurrent": false + }, + { + "fullName": "refs/heads/用户/修复 bug", + "shortName": "用户/修复 bug", + "kind": "local", + "isCurrent": false + } + ], + "checkedOutBranches": [ + "refs/heads/main" + ], + "mode": "newBranch", + "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "code": "invalid_request" + } + ] +} diff --git a/rust/fixtures/git/worktree-rules-v1.json b/rust/fixtures/git/worktree-rules-v1.json new file mode 100644 index 00000000..124f09df --- /dev/null +++ b/rust/fixtures/git/worktree-rules-v1.json @@ -0,0 +1,258 @@ +{ + "protocolVersion": 1, + "description": "State and allowed writes of registered worktrees as git.worktrees reports them; changes is null while they were not counted.", + "cases": [ + { + "name": "primary and current", + "worktree": { + "isPrimary": true, + "isCurrent": true, + "isLocked": false, + "isPrunable": false + }, + "changes": 0, + "status": "current", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_primary" + }, + "lock": { + "allowed": false, + "reason": "git_worktree_primary" + } + } + }, + { + "name": "primary from another worktree", + "worktree": { + "isPrimary": true, + "isCurrent": false, + "isLocked": false, + "isPrunable": false + }, + "changes": null, + "status": "available", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_primary" + }, + "lock": { + "allowed": false, + "reason": "git_worktree_primary" + } + } + }, + { + "name": "primary with changes", + "worktree": { + "isPrimary": true, + "isCurrent": true, + "isLocked": false, + "isPrunable": false + }, + "changes": 3, + "status": "modified", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_primary" + }, + "lock": { + "allowed": false, + "reason": "git_worktree_primary" + } + } + }, + { + "name": "linked and current", + "worktree": { + "isPrimary": false, + "isCurrent": true, + "isLocked": false, + "isPrunable": false + }, + "changes": null, + "status": "current", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_current" + }, + "lock": { + "allowed": true, + "reason": null + } + } + }, + { + "name": "linked", + "worktree": { + "isPrimary": false, + "isCurrent": false, + "isLocked": false, + "isPrunable": false + }, + "changes": 0, + "status": "available", + "actions": { + "remove": { + "allowed": true, + "reason": null + }, + "lock": { + "allowed": true, + "reason": null + } + } + }, + { + "name": "linked, changes not counted", + "worktree": { + "isPrimary": false, + "isCurrent": false, + "isLocked": false, + "isPrunable": false + }, + "changes": null, + "status": "available", + "actions": { + "remove": { + "allowed": true, + "reason": null + }, + "lock": { + "allowed": true, + "reason": null + } + } + }, + { + "name": "linked with changes", + "worktree": { + "isPrimary": false, + "isCurrent": false, + "isLocked": false, + "isPrunable": false + }, + "changes": 2, + "status": "modified", + "actions": { + "remove": { + "allowed": true, + "reason": null + }, + "lock": { + "allowed": true, + "reason": null + } + } + }, + { + "name": "locked", + "worktree": { + "isPrimary": false, + "isCurrent": false, + "isLocked": true, + "isPrunable": false + }, + "changes": 0, + "status": "locked", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_locked" + }, + "lock": { + "allowed": true, + "reason": null + } + } + }, + { + "name": "locked with changes", + "worktree": { + "isPrimary": false, + "isCurrent": false, + "isLocked": true, + "isPrunable": false + }, + "changes": 4, + "status": "locked", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_locked" + }, + "lock": { + "allowed": true, + "reason": null + } + } + }, + { + "name": "locked and current", + "worktree": { + "isPrimary": false, + "isCurrent": true, + "isLocked": true, + "isPrunable": false + }, + "changes": null, + "status": "locked", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_current" + }, + "lock": { + "allowed": true, + "reason": null + } + } + }, + { + "name": "missing", + "worktree": { + "isPrimary": false, + "isCurrent": false, + "isLocked": false, + "isPrunable": true + }, + "changes": null, + "status": "missing", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_missing" + }, + "lock": { + "allowed": false, + "reason": "git_worktree_missing" + } + } + }, + { + "name": "missing and locked", + "worktree": { + "isPrimary": false, + "isCurrent": false, + "isLocked": true, + "isPrunable": true + }, + "changes": null, + "status": "missing", + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_missing" + }, + "lock": { + "allowed": false, + "reason": "git_worktree_missing" + } + } + } + ] +} diff --git a/rust/fixtures/git/worktrees-v1.json b/rust/fixtures/git/worktrees-v1.json index 9b38c62c..cdcdd35c 100644 --- a/rust/fixtures/git/worktrees-v1.json +++ b/rust/fixtures/git/worktrees-v1.json @@ -12,7 +12,19 @@ "isLocked": false, "lockReason": null, "isPrunable": false, - "pruneReason": null + "pruneReason": null, + "status": "current", + "changes": null, + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_primary" + }, + "lock": { + "allowed": false, + "reason": "git_worktree_primary" + } + } }, { "path": "/example/worktrees/feature-core", @@ -25,7 +37,19 @@ "isLocked": true, "lockReason": "active task", "isPrunable": false, - "pruneReason": null + "pruneReason": null, + "status": "locked", + "changes": null, + "actions": { + "remove": { + "allowed": false, + "reason": "git_worktree_locked" + }, + "lock": { + "allowed": true, + "reason": null + } + } } ] } diff --git a/rust/fixtures/network/proxy-bypass-v1.json b/rust/fixtures/network/proxy-bypass-v1.json new file mode 100644 index 00000000..a895669a --- /dev/null +++ b/rust/fixtures/network/proxy-bypass-v1.json @@ -0,0 +1,139 @@ +{ + "protocolVersion": 1, + "description": "network.proxyPolicy reads the exclusions of a custom policy into exclusionRules and invalidExclusions; a host goes direct when bypasses(host, exclusionRules) is true. Hosts compare exactly as the contract states and replay these cases.", + "cases": [ + { + "name": "everyForm", + "exclusions": [ + "localhost", + "Example.COM.", + ".corp.example", + "*.Internal.Example", + "10.1.2.3/8", + "fd00::/8", + "192.168.0.10", + "[::1]", + "" + ], + "exclusionRules": [ + { + "kind": "host", + "value": "localhost" + }, + { + "kind": "host", + "value": "example.com" + }, + { + "kind": "suffix", + "value": "corp.example" + }, + { + "kind": "suffix", + "value": "internal.example" + }, + { + "kind": "cidr", + "value": "10.0.0.0/8" + }, + { + "kind": "cidr", + "value": "fd00::/8" + }, + { + "kind": "ip", + "value": "192.168.0.10" + }, + { + "kind": "ip", + "value": "::1" + }, + { + "kind": "local", + "value": "" + } + ], + "invalidExclusions": [], + "hosts": { + "localhost": true, + "LOCALHOST.": true, + "example.com": true, + "www.example.com": false, + "corp.example": true, + "git.corp.example": true, + "notcorp.example": false, + "internal.example": true, + "a.b.internal.example": true, + "10.200.0.1": true, + "11.0.0.1": false, + "fd12::1": true, + "fe80::1": false, + "fd12::1%en0": true, + "192.168.0.10": true, + "192.168.0.11": false, + "::ffff:192.168.0.10": false, + "::ffff:10.200.0.1": false, + "::1": true, + "intranet": true, + "printer.": true, + "": false + } + }, + { + "name": "unsupportedEntriesSendNothingDirect", + "exclusions": [ + "*", + "*.", + "10.0.0.0/33", + "10.0.0.0/x", + "example.com:8080", + "exa mple.com", + "*.*.example.com", + "a..b", + "10.0.0.0/8", + "10.0.0.0/8" + ], + "exclusionRules": [ + { + "kind": "cidr", + "value": "10.0.0.0/8" + } + ], + "invalidExclusions": [ + "*", + "*.", + "10.0.0.0/33", + "10.0.0.0/x", + "example.com:8080", + "exa mple.com", + "*.*.example.com", + "a..b" + ], + "hosts": { + "example.com": false, + "10.0.0.1": true, + "anything": false + } + }, + { + "name": "noLocalRuleForAddressesOrDottedNames", + "exclusions": [ + "" + ], + "exclusionRules": [ + { + "kind": "local", + "value": "" + } + ], + "invalidExclusions": [], + "hosts": { + "intranet": true, + "intranet.example": false, + "127.0.0.1": false, + "::1": false, + "fe80::1%en0": false + } + } + ] +} diff --git a/rust/fixtures/network/proxy-policy-v1.json b/rust/fixtures/network/proxy-policy-v1.json index 1ab25759..6f0bced5 100644 --- a/rust/fixtures/network/proxy-policy-v1.json +++ b/rust/fixtures/network/proxy-policy-v1.json @@ -22,6 +22,8 @@ "username": "", "password": "", "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [], "configured": false } }, @@ -41,6 +43,8 @@ "username": "", "password": "", "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [], "configured": false } }, @@ -70,6 +74,17 @@ "127.0.0.1", "*.internal.example" ], + "exclusionRules": [ + { + "kind": "ip", + "value": "127.0.0.1" + }, + { + "kind": "suffix", + "value": "internal.example" + } + ], + "invalidExclusions": [], "configured": true } }, @@ -89,6 +104,8 @@ "username": "", "password": "", "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [], "configured": true } }, @@ -107,6 +124,8 @@ "username": "", "password": "", "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [], "configured": true } }, @@ -125,6 +144,8 @@ "username": "", "password": "", "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [], "configured": false } }, @@ -143,6 +164,8 @@ "username": "", "password": "", "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [], "configured": false } }, @@ -170,7 +193,9 @@ "password": "sample-value", "policy": "custom", "configured": true, - "exclusions": [] + "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [] } }, { @@ -197,7 +222,9 @@ "password": "", "policy": "custom", "configured": true, - "exclusions": [] + "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [] } }, { @@ -224,7 +251,9 @@ "password": "", "policy": "custom", "configured": true, - "exclusions": [] + "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [] } }, { @@ -251,7 +280,9 @@ "password": "", "policy": "custom", "configured": true, - "exclusions": [] + "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [] } }, { @@ -278,7 +309,9 @@ "password": "", "policy": "custom", "configured": true, - "exclusions": [] + "exclusions": [], + "exclusionRules": [], + "invalidExclusions": [] } } ] diff --git a/rust/fixtures/plugins/installed-lifecycle.json b/rust/fixtures/plugins/installed-lifecycle.json new file mode 100644 index 00000000..d2351692 --- /dev/null +++ b/rust/fixtures/plugins/installed-lifecycle.json @@ -0,0 +1,173 @@ +{ + "description": "plugins.installed.lifecycle: installed plugins' enablement and the ordered steps of a lifecycle action. Hosts carry the steps out on the action's plugin.", + "cases": [ + { + "name": "a plugin without a choice is disabled, per plugin not per version", + "request": { + "installed": [ + { "pluginID": "other", "version": "2.0.0" }, + { "pluginID": "example", "version": "1.1.0" }, + { "pluginID": "example", "version": "1.0.0" } + ], + "choices": [ + { "pluginID": "example", "enabled": true }, + { "pluginID": "absent", "enabled": true } + ] + }, + "response": { + "plugins": [ + { "pluginID": "example", "enabled": true }, + { "pluginID": "other", "enabled": false } + ], + "changed": false, + "steps": [] + } + }, + { + "name": "confirming an installation enables a plugin without a choice", + "request": { + "installed": [{ "pluginID": "fresh", "version": "1.0.0" }], + "action": { "kind": "confirmInstall", "pluginID": "fresh" } + }, + "response": { + "plugins": [{ "pluginID": "fresh", "enabled": true }], + "changed": true, + "steps": [{ "kind": "recordChoice", "enabled": true }] + } + }, + { + "name": "confirming an update keeps a plugin the user disabled disabled", + "request": { + "installed": [ + { "pluginID": "disabled", "version": "1.0.0" }, + { "pluginID": "disabled", "version": "1.1.0" } + ], + "choices": [{ "pluginID": "disabled", "enabled": false }], + "action": { "kind": "confirmInstall", "pluginID": "disabled" } + }, + "response": { + "plugins": [{ "pluginID": "disabled", "enabled": false }], + "changed": false, + "steps": [] + } + }, + { + "name": "enabling records the choice", + "request": { + "installed": [{ "pluginID": "example", "version": "1.0.0" }], + "choices": [{ "pluginID": "example", "enabled": false }], + "action": { "kind": "enable", "pluginID": "example" } + }, + "response": { + "plugins": [{ "pluginID": "example", "enabled": true }], + "changed": true, + "steps": [{ "kind": "recordChoice", "enabled": true }] + } + }, + { + "name": "enabling an enabled plugin has nothing to do", + "request": { + "installed": [{ "pluginID": "example", "version": "1.0.0" }], + "choices": [{ "pluginID": "example", "enabled": true }], + "action": { "kind": "enable", "pluginID": "example" } + }, + "response": { + "plugins": [{ "pluginID": "example", "enabled": true }], + "changed": false, + "steps": [] + } + }, + { + "name": "disabling records the choice before it stops the programs", + "request": { + "installed": [{ "pluginID": "example", "version": "1.0.0" }], + "choices": [{ "pluginID": "example", "enabled": true }], + "action": { "kind": "disable", "pluginID": "example" } + }, + "response": { + "plugins": [{ "pluginID": "example", "enabled": false }], + "changed": true, + "steps": [ + { "kind": "recordChoice", "enabled": false }, + { "kind": "stopPrograms" } + ] + } + }, + { + "name": "disabling a disabled plugin still stops its programs", + "request": { + "installed": [{ "pluginID": "example", "version": "1.0.0" }], + "choices": [{ "pluginID": "example", "enabled": false }], + "action": { "kind": "disable", "pluginID": "example" } + }, + "response": { + "plugins": [{ "pluginID": "example", "enabled": false }], + "changed": false, + "steps": [{ "kind": "stopPrograms" }] + } + }, + { + "name": "uninstalling stops the programs, removes every version, forgets the choice and removes the state", + "request": { + "installed": [ + { "pluginID": "example", "version": "1.1.0" }, + { "pluginID": "other", "version": "1.0.0" }, + { "pluginID": "example", "version": "1.0.0" } + ], + "choices": [ + { "pluginID": "example", "enabled": false }, + { "pluginID": "other", "enabled": true } + ], + "action": { "kind": "uninstall", "pluginID": "example" } + }, + "response": { + "plugins": [{ "pluginID": "other", "enabled": true }], + "changed": true, + "steps": [ + { "kind": "stopPrograms" }, + { "kind": "removeVersion", "version": "1.0.0" }, + { "kind": "removeVersion", "version": "1.1.0" }, + { "kind": "forgetChoice" }, + { "kind": "removeState" } + ] + } + }, + { + "name": "uninstalling a plugin without a choice has no choice to forget", + "request": { + "installed": [{ "pluginID": "example", "version": "1.0.0" }], + "action": { "kind": "uninstall", "pluginID": "example" } + }, + "response": { + "plugins": [], + "changed": false, + "steps": [ + { "kind": "stopPrograms" }, + { "kind": "removeVersion", "version": "1.0.0" }, + { "kind": "removeState" } + ] + } + }, + { + "name": "an action on a plugin that is not installed is rejected", + "request": { + "installed": [{ "pluginID": "example", "version": "1.0.0" }], + "choices": [{ "pluginID": "absent", "enabled": false }], + "action": { "kind": "enable", "pluginID": "absent" } + }, + "response": { + "plugins": [{ "pluginID": "example", "enabled": false }], + "changed": false, + "steps": [], + "rejection": "plugin.lifecycle.notInstalled" + } + } + ], + "invalid": [ + { "name": "an empty action plugin ID", "request": { "installed": [], "action": { "kind": "enable", "pluginID": "" } } }, + { "name": "an empty installed version", "request": { "installed": [{ "pluginID": "example", "version": "" }] } }, + { "name": "a repeated installed version", "request": { "installed": [{ "pluginID": "example", "version": "1.0.0" }, { "pluginID": "example", "version": "1.0.0" }] } }, + { "name": "two choices for one plugin", "request": { "installed": [], "choices": [{ "pluginID": "example", "enabled": true }, { "pluginID": "example", "enabled": false }] } }, + { "name": "an unknown action", "request": { "installed": [], "action": { "kind": "reinstall", "pluginID": "example" } } } + ] +} diff --git a/rust/fixtures/process-lifecycle/exit-plan-v1.json b/rust/fixtures/process-lifecycle/exit-plan-v1.json index 1a2bdd65..a0cb1025 100644 --- a/rust/fixtures/process-lifecycle/exit-plan-v1.json +++ b/rust/fixtures/process-lifecycle/exit-plan-v1.json @@ -17,6 +17,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "idle-shell", "kind": "terminal", "policy": "terminate", "action": "terminate", "reason": "idleTerminal"}, {"id": "spring", "kind": "terminal", "policy": "confirmEach", "action": "confirm", "reason": "busyTerminal"}, @@ -46,6 +47,7 @@ }, "expected": { "gracefulStopMilliseconds": 12000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "spring", "kind": "terminal", "policy": "confirmEach", "action": "terminate", "reason": "terminalOnQuitSetting"}, {"id": "migration", "kind": "agentTask", "policy": "confirmEach", "action": "retain", "reason": "agentTasksSetting"}, @@ -69,6 +71,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "gradle", "kind": "daemon", "policy": "askOnExit", "action": "retain", "reason": "rememberedChoice"}, {"id": "kotlin", "kind": "daemon", "policy": "askOnExit", "action": "terminate", "reason": "rememberedChoice"}, @@ -88,6 +91,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "declared", "kind": "pluginTool", "policy": "terminate", "action": "terminate", "reason": "declaredByLauncher"}, {"id": "recognized", "kind": "pluginTool", "policy": "terminate", "action": "terminate", "reason": "recognizer"}, @@ -106,6 +110,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "busy", "kind": "terminal", "policy": "confirmEach", "action": "confirm", "reason": "busyTerminal"}, {"id": "detached-from-tab", "kind": "oneShot", "policy": "askOnExit", "action": "defer", "reason": "decidedOnQuit"} @@ -123,6 +128,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "busy", "kind": "terminal", "policy": "confirmEach", "action": "terminate", "reason": "terminalTabCloseSetting"} ] @@ -140,6 +146,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "deploy", "kind": "agentTask", "policy": "confirmEach", "action": "terminate", "reason": "agentTasksSetting"}, {"id": "mcp-daemon", "kind": "daemon", "policy": "askOnExit", "action": "defer", "reason": "decidedOnQuit"} @@ -160,6 +167,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "migration", "kind": "agentTask", "policy": "confirmEach", "action": "retain", "reason": "agentTaskNeverEndedUnattended"}, {"id": "spring", "kind": "terminal", "policy": "confirmEach", "action": "terminate", "reason": "busyTerminal"}, @@ -181,6 +189,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "migration", "kind": "agentTask", "policy": "confirmEach", "action": "confirm", "reason": "agentTaskAlwaysConfirmedAtStartup"}, {"id": "orphan", "kind": "daemon", "policy": "askOnExit", "action": "terminate", "reason": "startupSweepSetting"}, @@ -198,6 +207,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "orphan", "kind": "oneShot", "policy": "askOnExit", "action": "ask", "reason": "startupSweepSetting"} ] @@ -217,6 +227,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "unseen-successor", "kind": "daemon", "policy": "askOnExit", "action": "defer", "reason": "backgroundResourceRequiresStartupConfirmation"}, {"id": "approved-service", "kind": "daemon", "policy": "askOnExit", "action": "terminate", "reason": "finalSweepApproved"}, @@ -238,6 +249,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "unseen-service", "kind": "daemon", "policy": "askOnExit", "action": "terminate", "reason": "backgroundSetting"}, {"id": "remembered-service", "kind": "daemon", "policy": "askOnExit", "action": "retain", "reason": "rememberedChoice"} @@ -256,6 +268,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "api", "kind": "run", "policy": "terminate", "action": "confirmStop", "reason": "runServicesSetting"}, {"id": "run:other", "kind": "run", "policy": "terminate", "action": "terminate", "reason": "defaultTerminate"}, @@ -274,6 +287,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "api", "kind": "run", "policy": "terminate", "action": "terminate", "reason": "runServicesSetting"} ] @@ -295,6 +309,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "api", "kind": "run", "policy": "terminate", "action": "confirmStop", "reason": "runServicesSetting"}, {"id": "kept-api", "kind": "run", "policy": "terminate", "action": "retain", "reason": "retainedByUser"}, @@ -316,6 +331,7 @@ }, "expected": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ {"id": "api", "kind": "run", "policy": "terminate", "action": "terminate", "reason": "defaultTerminate"} ] diff --git a/rust/fixtures/process-lifecycle/inventory-v1.json b/rust/fixtures/process-lifecycle/inventory-v1.json index e6f045e7..34faccc8 100644 --- a/rust/fixtures/process-lifecycle/inventory-v1.json +++ b/rust/fixtures/process-lifecycle/inventory-v1.json @@ -341,6 +341,7 @@ ], "plan": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ { "id": "agentTask:402:2500", @@ -540,6 +541,7 @@ ], "plan": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ { "id": "agentTask:400:1000", @@ -718,6 +720,7 @@ ], "plan": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ { "id": "service:350:300", @@ -1120,6 +1123,7 @@ ], "plan": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ { "id": "agentTask:111:30", @@ -1227,6 +1231,7 @@ ], "plan": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ { "id": "agentTask:111:30", @@ -1288,6 +1293,7 @@ ], "plan": { "gracefulStopMilliseconds": 5000, + "gracefulStopRule": {"defaultSeconds": 5, "minSeconds": 1, "maxSeconds": 60}, "decisions": [ { "id": "run:other", diff --git a/rust/fixtures/workspace/project-tree-rows-v1.json b/rust/fixtures/workspace/project-tree-rows-v1.json new file mode 100644 index 00000000..f54c3fdd --- /dev/null +++ b/rust/fixtures/workspace/project-tree-rows-v1.json @@ -0,0 +1,558 @@ +{ + "description": "workspace.projectTree.rows: the rows of the project tree from the nodes the host loaded, in preorder with their depth and whether each folder is open, and the selection kept on a row that shows. rows are positions in nodes; topRow is the row of the node named by top while it shows. Hosts list folders, keep focus, scrolling and painting, and ask once per change of shape, never per paint.", + "cases": [ + { + "name": "a row shows when every folder above it is open", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1, + "open": true + }, + { + "path": "a/x", + "depth": 2 + }, + { + "path": "a/y", + "depth": 2, + "open": true + }, + { + "path": "a/y/z", + "depth": 3 + }, + { + "path": "b", + "depth": 1 + }, + { + "path": "b/q", + "depth": 2 + }, + { + "path": "c", + "depth": 1 + } + ], + "selection": { + "selected": "a/x" + }, + "top": "a/y" + }, + "response": { + "rows": [ + 0, + 1, + 2, + 3, + 4, + 5, + 7 + ], + "selection": { + "selected": "a/x", + "marked": [] + }, + "selectionMoved": false, + "topRow": 3, + "targets": [] + } + }, + { + "name": "closing a folder hides its rows, moves the selection to it and keeps the marks inside", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1 + }, + { + "path": "a/x", + "depth": 2 + }, + { + "path": "a/y", + "depth": 2, + "open": true + }, + { + "path": "a/y/z", + "depth": 3 + }, + { + "path": "b", + "depth": 1 + }, + { + "path": "b/q", + "depth": 2 + }, + { + "path": "c", + "depth": 1 + } + ], + "selection": { + "selected": "a/y/z", + "marked": [ + "a/x" + ], + "anchor": "a/x" + }, + "top": "a/y" + }, + "response": { + "rows": [ + 0, + 1, + 5, + 7 + ], + "selection": { + "selected": "a", + "marked": [ + "a/x" + ], + "anchor": "a/x" + }, + "selectionMoved": true, + "topRow": null, + "targets": [ + "a/x" + ] + } + }, + { + "name": "a selected node that went away gives the selection to the nearest loaded folder, and marks and an anchor that went away are dropped", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1, + "open": true + }, + { + "path": "a/x", + "depth": 2 + } + ], + "selection": { + "selected": "a/y/z", + "marked": [ + "a/x", + "a/y", + "b" + ], + "anchor": "b" + } + }, + "response": { + "rows": [ + 0, + 1, + 2 + ], + "selection": { + "selected": "a", + "marked": [ + "a/x" + ] + }, + "selectionMoved": true, + "topRow": null, + "targets": [ + "a/x" + ] + } + }, + { + "name": "a top-level node that went away gives the selection to the root", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "c", + "depth": 1 + } + ], + "selection": { + "selected": "b" + }, + "top": "c" + }, + "response": { + "rows": [ + 0, + 1 + ], + "selection": { + "selected": "", + "marked": [] + }, + "selectionMoved": true, + "topRow": 1, + "targets": [] + } + }, + { + "name": "the root shows while it is closed", + "request": { + "nodes": [ + { + "path": "", + "depth": 0 + }, + { + "path": "a", + "depth": 1 + } + ], + "selection": { + "selected": "a" + } + }, + "response": { + "rows": [ + 0 + ], + "selection": { + "selected": "", + "marked": [] + }, + "selectionMoved": true, + "topRow": null, + "targets": [] + } + }, + { + "name": "no selection stays no selection", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1 + } + ] + }, + "response": { + "rows": [ + 0, + 1 + ], + "selection": { + "selected": null, + "marked": [] + }, + "selectionMoved": false, + "topRow": null, + "targets": [] + } + }, + { + "name": "an open folder listed empty shows alone", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1, + "open": true + }, + { + "path": "b", + "depth": 1 + } + ], + "selection": { + "selected": "a" + } + }, + "response": { + "rows": [ + 0, + 1, + 2 + ], + "selection": { + "selected": "a", + "marked": [] + }, + "selectionMoved": false, + "topRow": null, + "targets": [] + } + }, + { + "name": "an entry inserted above keeps the top row by path", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1 + }, + { + "path": "new", + "depth": 1 + }, + { + "path": "z", + "depth": 1 + } + ], + "selection": { + "selected": "z" + }, + "top": "z" + }, + "response": { + "rows": [ + 0, + 1, + 2, + 3 + ], + "selection": { + "selected": "z", + "marked": [] + }, + "selectionMoved": false, + "topRow": 3, + "targets": [] + } + }, + { + "name": "a top that went away has no row", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1 + } + ], + "top": "gone" + }, + "response": { + "rows": [ + 0, + 1 + ], + "selection": { + "selected": null, + "marked": [] + }, + "selectionMoved": false, + "topRow": null, + "targets": [] + } + } + ], + "invalid": [ + { + "name": "no nodes", + "request": { + "nodes": [] + } + }, + { + "name": "the first node is not the root", + "request": { + "nodes": [ + { + "path": "a", + "depth": 1 + } + ] + } + }, + { + "name": "the root is not at depth 0", + "request": { + "nodes": [ + { + "path": "", + "depth": 1 + } + ] + } + }, + { + "name": "a node skips a depth", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1, + "open": true + }, + { + "path": "a/b/c", + "depth": 3 + } + ] + } + }, + { + "name": "a node outside the folder above it", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1, + "open": true + }, + { + "path": "b/c", + "depth": 2 + } + ] + } + }, + { + "name": "a node more than one name below its folder", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a/b", + "depth": 1 + } + ] + } + }, + { + "name": "a second root", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "", + "depth": 1 + } + ] + } + }, + { + "name": "a second node at depth 0", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 0 + } + ] + } + }, + { + "name": "a path listed twice", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + }, + { + "path": "a", + "depth": 1 + }, + { + "path": "a", + "depth": 1 + } + ] + } + }, + { + "name": "a marked root", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "open": true + } + ], + "selection": { + "marked": [ + "" + ] + } + } + }, + { + "name": "an unknown node field", + "request": { + "nodes": [ + { + "path": "", + "depth": 0, + "kind": "folder" + } + ] + } + } + ] +} diff --git a/rust/fixtures/workspace/row-selection-v1.json b/rust/fixtures/workspace/row-selection-v1.json new file mode 100644 index 00000000..9784ff3e --- /dev/null +++ b/rust/fixtures/workspace/row-selection-v1.json @@ -0,0 +1,612 @@ +{ + "description": "workspace.rowSelection.reduce: one click on rows that show workspace paths, such as the project tree and the changes of a change list. A plain click (choose) selects and drops the marks and the anchor; Cmd or Ctrl (toggle) marks or unmarks and sets the anchor; Shift (extend) marks from the anchor, else the selected row, to the clicked row and keeps other marks; clear ends a batch. The empty path is the workspace root and is never marked. targets is what a batch acts on: the marks without those inside a marked folder. Hosts keep focus, painting and the batch itself.", + "cases": [ + { + "name": "a plain click selects its row and drops the marks and the anchor", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "choose", + "path": "c" + }, + "selection": { + "selected": "a", + "marked": [ + "b", + "c" + ], + "anchor": "b" + } + }, + "response": { + "selection": { + "selected": "c", + "marked": [] + }, + "targets": [] + } + }, + { + "name": "a plain click on the root selects it", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "choose", + "path": "" + }, + "selection": { + "selected": "a", + "marked": [ + "b" + ] + } + }, + "response": { + "selection": { + "selected": "", + "marked": [] + }, + "targets": [] + } + }, + { + "name": "a Cmd or Ctrl click marks its row and makes it the anchor", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "toggle", + "path": "b" + }, + "selection": { + "selected": "a" + } + }, + "response": { + "selection": { + "selected": "b", + "marked": [ + "b" + ], + "anchor": "b" + }, + "targets": [ + "b" + ] + } + }, + { + "name": "a Cmd or Ctrl click on a marked row unmarks it", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "toggle", + "path": "b" + }, + "selection": { + "selected": "b", + "marked": [ + "a", + "b" + ], + "anchor": "a" + } + }, + "response": { + "selection": { + "selected": "b", + "marked": [ + "a" + ], + "anchor": "b" + }, + "targets": [ + "a" + ] + } + }, + { + "name": "a Shift click marks from the anchor and keeps the other marks", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "extend", + "path": "b" + }, + "selection": { + "selected": "a", + "marked": [ + "c" + ], + "anchor": "a/x" + } + }, + "response": { + "selection": { + "selected": "b", + "marked": [ + "a/x", + "b", + "c" + ], + "anchor": "a/x" + }, + "targets": [ + "a/x", + "b", + "c" + ] + } + }, + { + "name": "a Shift click without an anchor marks from the selected row, and a marked folder stands for its contents", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "extend", + "path": "a" + }, + "selection": { + "selected": "b" + } + }, + "response": { + "selection": { + "selected": "a", + "marked": [ + "a", + "a/x", + "b" + ] + }, + "targets": [ + "a", + "b" + ] + } + }, + { + "name": "a Shift click with neither an anchor nor a selected row marks the clicked row", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "extend", + "path": "b" + } + }, + "response": { + "selection": { + "selected": "b", + "marked": [ + "b" + ] + }, + "targets": [ + "b" + ] + } + }, + { + "name": "a Shift click whose anchor is hidden extends from the selected row and keeps the anchor", + "request": { + "rows": [ + "", + "a", + "b", + "c" + ], + "action": { + "kind": "extend", + "path": "b" + }, + "selection": { + "selected": "c", + "anchor": "a/x" + } + }, + "response": { + "selection": { + "selected": "b", + "marked": [ + "b", + "c" + ], + "anchor": "a/x" + }, + "targets": [ + "b", + "c" + ] + } + }, + { + "name": "a range that covers the root never marks it", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "extend", + "path": "a" + }, + "selection": { + "selected": "" + } + }, + "response": { + "selection": { + "selected": "a", + "marked": [ + "a" + ] + }, + "targets": [ + "a" + ] + } + }, + { + "name": "a Cmd or Ctrl click on the root changes nothing", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "toggle", + "path": "" + }, + "selection": { + "selected": "a", + "marked": [ + "b" + ], + "anchor": "b" + } + }, + "response": { + "selection": { + "selected": "a", + "marked": [ + "b" + ], + "anchor": "b" + }, + "targets": [ + "b" + ] + } + }, + { + "name": "a Shift click on the root changes nothing", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "extend", + "path": "" + }, + "selection": { + "selected": "a", + "marked": [ + "b" + ], + "anchor": "b" + } + }, + "response": { + "selection": { + "selected": "a", + "marked": [ + "b" + ], + "anchor": "b" + }, + "targets": [ + "b" + ] + } + }, + { + "name": "marks of rows that do not show stay", + "request": { + "rows": [ + "", + "a", + "b" + ], + "action": { + "kind": "toggle", + "path": "b" + }, + "selection": { + "selected": "a", + "marked": [ + "a/x" + ] + } + }, + "response": { + "selection": { + "selected": "b", + "marked": [ + "a/x", + "b" + ], + "anchor": "b" + }, + "targets": [ + "a/x", + "b" + ] + } + }, + { + "name": "clear drops the marks and the anchor and keeps the selected row", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "clear" + }, + "selection": { + "selected": "b", + "marked": [ + "a", + "b" + ], + "anchor": "a" + } + }, + "response": { + "selection": { + "selected": "b", + "marked": [] + }, + "targets": [] + } + }, + { + "name": "a plain click on a row that went away is stale", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "choose", + "path": "gone" + }, + "selection": { + "selected": "a", + "marked": [ + "b" + ], + "anchor": "b" + } + }, + "response": { + "selection": { + "selected": "a", + "marked": [ + "b" + ], + "anchor": "b" + }, + "targets": [ + "b" + ], + "rejection": "workspace.rowSelection.stale" + } + }, + { + "name": "a Cmd or Ctrl click on a row that went away is stale", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "toggle", + "path": "gone" + }, + "selection": { + "selected": "a" + } + }, + "response": { + "selection": { + "selected": "a", + "marked": [] + }, + "targets": [], + "rejection": "workspace.rowSelection.stale" + } + }, + { + "name": "a Shift click on a row that went away is stale", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "extend", + "path": "gone" + }, + "selection": { + "selected": "a" + } + }, + "response": { + "selection": { + "selected": "a", + "marked": [] + }, + "targets": [], + "rejection": "workspace.rowSelection.stale" + } + } + ], + "invalid": [ + { + "name": "the root cannot be marked", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "selection": { + "marked": [ + "" + ] + }, + "action": { + "kind": "clear" + } + } + }, + { + "name": "the root cannot be the anchor", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "selection": { + "anchor": "" + }, + "action": { + "kind": "clear" + } + } + }, + { + "name": "an unknown action", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "selectAll" + } + } + }, + { + "name": "an action without its path", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "toggle" + } + } + }, + { + "name": "an unknown field", + "request": { + "rows": [ + "", + "a", + "a/x", + "b", + "c" + ], + "action": { + "kind": "clear" + }, + "focus": "a" + } + }, + { + "name": "no rows", + "request": { + "action": { + "kind": "clear" + } + } + } + ] +}