From 8843bbb0c4dc4e6b52ca5d0a6592ce80be56a19c Mon Sep 17 00:00:00 2001 From: Can Celik Date: Sun, 26 Jul 2026 02:45:44 +0300 Subject: [PATCH] fix: improve sidebar worktree hierarchy and reordering (#1873) * fix: clarify sidebar worktree hierarchy refs #1694 * feat: reorder worktree groups as a unit refs #1694 --- docs/next/api/herdr-api.schema.json | 114 +++++++ .../src/content/docs/ja/socket-api.mdx | 6 +- .../website/src/content/docs/socket-api.mdx | 6 +- .../src/content/docs/zh-cn/socket-api.mdx | 6 +- src/api/mod.rs | 1 + src/api/schema.rs | 2 + src/api/schema/events.rs | 12 + src/api/schema/tests.rs | 22 ++ src/api/schema/workspaces.rs | 7 + src/api/server.rs | 1 + src/api/subscriptions.rs | 4 + src/app/actions.rs | 120 +++++++ src/app/api.rs | 3 + src/app/api/plugins/context.rs | 6 + src/app/api/workspaces.rs | 126 ++++++- src/app/input/mod.rs | 3 + src/app/input/mouse.rs | 54 ++- src/app/input/navigate.rs | 7 + src/app/input/sidebar.rs | 322 +++++++++++++++--- src/app/runtime_mutations.rs | 14 +- src/app/state.rs | 19 +- src/ui.rs | 10 +- src/ui/sidebar.rs | 277 ++++++++++++--- 23 files changed, 1005 insertions(+), 137 deletions(-) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index dc6dce83..59a0c6a3 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -204,6 +204,38 @@ ], "type": "object" }, + { + "properties": { + "before_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "workspace_reordered", + "type": "string" + }, + "workspace_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspaces": { + "items": { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + }, + "type": "array" + } + }, + "required": [ + "type", + "workspace_ids", + "workspaces" + ], + "type": "object" + }, { "properties": { "type": { @@ -702,6 +734,7 @@ "workspace_closed", "workspace_renamed", "workspace_moved", + "workspace_reordered", "workspace_focused", "worktree_created", "worktree_opened", @@ -3662,6 +3695,18 @@ ], "type": "object" }, + { + "properties": { + "type": { + "const": "workspace.reordered", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, { "properties": { "type": { @@ -4081,6 +4126,26 @@ }, "type": "object" }, + "WorkspaceMoveBlockParams": { + "properties": { + "before_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "workspace_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "workspace_ids" + ], + "type": "object" + }, "WorkspaceMoveParams": { "properties": { "insert_index": { @@ -4572,6 +4637,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "workspace.move_block", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/WorkspaceMoveBlockParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -6335,6 +6416,38 @@ ], "type": "object" }, + { + "properties": { + "before_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "workspace_reordered", + "type": "string" + }, + "workspace_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspaces": { + "items": { + "$ref": "#/schemas/success_response/$defs/WorkspaceInfo" + }, + "type": "array" + } + }, + "required": [ + "type", + "workspace_ids", + "workspaces" + ], + "type": "object" + }, { "properties": { "type": { @@ -6848,6 +6961,7 @@ "workspace_closed", "workspace_renamed", "workspace_moved", + "workspace_reordered", "workspace_focused", "worktree_created", "worktree_opened", diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index 8995dbae..7b9ee4cc 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -96,7 +96,7 @@ herdr pane read w1:p2 --source recent --lines 50 | 通知 | `notification.show` | | クライアント | `client.window_title.set`、`client.window_title.clear` | | セッション | `session.snapshot` | -| ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.report_metadata`、`workspace.close` | +| ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | | Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | | タブ | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | | ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | @@ -109,6 +109,8 @@ herdr pane read w1:p2 --source recent --lines 50 `agent.wait` はサーバー所有でイベント駆動です。解決したペイン占有者に固定されるため、置換されたエージェントが待機を満たすことはありません。`agent.prompt` は `until` と `timeout_ms` を持つ省略可能な `wait` オブジェクトを受け付けます。これにより、プロンプト送信と待機開始を 1 つのリクエストで行い、別々の呼び出し間の競合を避けられます。 +`workspace.move_block` は、順序付きの `workspace_ids` を `before_workspace_id` の前へアトミックに移動します。アンカーを省略するとブロックを末尾へ移動します。id は一意である必要があり、アンカーをブロックに含めることはできません。レスポンスにはサーバーが確定した順序付きワークスペース一覧が含まれます。 + `session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。取得後はリソースイベントを購読し、そのイベントでローカルキャッシュを更新してください。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。 CLI の `herdr api snapshot` は、クライアントやエージェントが簡単にブートストラップできるよう、現在の `session.snapshot` レスポンスを JSON で出力します。 @@ -635,7 +637,7 @@ workspace の get/list 応答は結果の `tokens` マップを公開し、ス 最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。 -ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。 +ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。`workspace.reordered` は、アトミックに移動した `workspace_ids`、省略可能な `before_workspace_id`、サーバーが確定した順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。 タブのイベント購読には `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed`、`tab.moved` があります。`tab.moved` は、移動した `tab_id`、`workspace_id`、要求された `insert_index`、そのワークスペースの更新済みの順序付き `tabs` リストを含みます。 ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。 レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でブートストラップするクライアントは、同じ `workspace_id` と `tab_id` のキャッシュ済みレイアウトを置き換えてください。 diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index db62db7f..0e9dd71b 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -100,7 +100,7 @@ Raw socket method names use dot notation: | Notification | `notification.show` | | Client | `client.window_title.set`, `client.window_title.clear` | | Session | `session.snapshot` | -| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.report_metadata`, `workspace.close` | +| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.move_block`, `workspace.report_metadata`, `workspace.close` | | Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | | Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` | | Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.graphics.info`, `pane.graphics.set`, `pane.graphics.clear`, `pane.graphics.stream`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | @@ -113,6 +113,8 @@ Raw socket method names use dot notation: `agent.wait` is server-owned and event-driven. It pins the resolved pane occupant so a replacement cannot satisfy the wait. `agent.prompt` accepts an optional `wait` object with `until` and `timeout_ms`; this submits the prompt and starts the wait in one request, avoiding a race between separate calls. +`workspace.move_block` atomically moves the ordered `workspace_ids` before `before_workspace_id`; omit the anchor to move the block to the end. The ids must be unique and the anchor cannot be part of the block. The response contains the authoritative ordered workspace list. + `session.snapshot` returns a one-time bootstrap snapshot for clients that keep their own local runtime cache. The response includes version/protocol metadata, focused workspace/tab/pane ids, workspace records, tab records, pane records, @@ -758,7 +760,7 @@ Subscribe to events when you need a long-lived stream: The first response acknowledges the subscription. Later lines are pushed events. -Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal. +Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.reordered`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.reordered` includes the atomically moved `workspace_ids`, optional `before_workspace_id`, and authoritative ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal. Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`, `tab.renamed`, and `tab.moved`. `tab.moved` includes the moved `tab_id`, `workspace_id`, requested `insert_index`, and updated ordered `tabs` list for diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index 2077d571..269e6a3d 100644 --- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx @@ -96,7 +96,7 @@ herdr pane read w1:p2 --source recent --lines 50 | 通知 | `notification.show` | | 客户端 | `client.window_title.set`、`client.window_title.clear` | | 会话 | `session.snapshot` | -| 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.report_metadata`、`workspace.close` | +| 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | | Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | | 标签页 | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | | 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | @@ -109,6 +109,8 @@ herdr pane read w1:p2 --source recent --lines 50 `agent.wait` 由服务器拥有并由事件驱动。它会固定到已解析的窗格占用者,因此替换后的智能体不能满足该等待。`agent.prompt` 接受可选的 `wait` 对象,其中包含 `until` 和 `timeout_ms`;这样可在一个请求中提交提示并开始等待,避免两个独立调用之间的竞态。 +`workspace.move_block` 会将有序的 `workspace_ids` 原子地移动到 `before_workspace_id` 之前;省略锚点则将该块移动到末尾。id 必须唯一,且锚点不能属于被移动的块。响应包含服务器确认的有序工作区列表。 + `session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅;读取后应订阅资源事件,并根据事件更新本地缓存。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。 CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSON,让客户端和智能体可以简单地完成引导。 @@ -631,7 +633,7 @@ workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏 第一个响应确认订阅。之后的行是推送的事件。 -工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。 +工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。`workspace.reordered` 包含原子移动的 `workspace_ids`、可选的 `before_workspace_id` 和服务器确认的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。 标签页事件订阅包括 `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed` 和 `tab.moved`。`tab.moved` 包含被移动的 `tab_id`、`workspace_id`、请求的 `insert_index` 和该工作区更新后的有序 `tabs` 列表。 窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。 布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 引导的客户端应替换具有相同 `workspace_id` 和 `tab_id` 的缓存布局。 diff --git a/src/api/mod.rs b/src/api/mod.rs index 994b2d87..7520d83c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -29,6 +29,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) | Method::WorkspaceMove(_) + | Method::WorkspaceMoveBlock(_) | Method::WorkspaceReportMetadata(_) | Method::WorkspaceClose(_) | Method::WorktreeCreate(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 72d9e53a..42179c06 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -75,6 +75,8 @@ pub enum Method { WorkspaceRename(WorkspaceRenameParams), #[serde(rename = "workspace.move")] WorkspaceMove(WorkspaceMoveParams), + #[serde(rename = "workspace.move_block")] + WorkspaceMoveBlock(WorkspaceMoveBlockParams), #[serde(rename = "workspace.report_metadata")] WorkspaceReportMetadata(WorkspaceReportMetadataParams), #[serde(rename = "workspace.close")] diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 50b5d522..52ac6c96 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -26,6 +26,8 @@ pub enum Subscription { WorkspaceRenamed {}, #[serde(rename = "workspace.moved")] WorkspaceMoved {}, + #[serde(rename = "workspace.reordered")] + WorkspaceReordered {}, #[serde(rename = "workspace.closed")] WorkspaceClosed {}, #[serde(rename = "workspace.focused")] @@ -196,6 +198,7 @@ pub enum EventKind { WorkspaceClosed, WorkspaceRenamed, WorkspaceMoved, + WorkspaceReordered, WorkspaceFocused, WorktreeCreated, WorktreeOpened, @@ -226,6 +229,7 @@ impl EventKind { EventKind::WorkspaceClosed => "workspace.closed", EventKind::WorkspaceRenamed => "workspace.renamed", EventKind::WorkspaceMoved => "workspace.moved", + EventKind::WorkspaceReordered => "workspace.reordered", EventKind::WorkspaceFocused => "workspace.focused", EventKind::WorktreeCreated => "worktree.created", EventKind::WorktreeOpened => "worktree.opened", @@ -257,6 +261,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ EventKind::WorkspaceClosed, EventKind::WorkspaceRenamed, EventKind::WorkspaceMoved, + EventKind::WorkspaceReordered, EventKind::WorkspaceFocused, EventKind::WorktreeCreated, EventKind::WorktreeOpened, @@ -284,6 +289,7 @@ pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[ EventKind::WorkspaceClosed, EventKind::WorkspaceRenamed, EventKind::WorkspaceMoved, + EventKind::WorkspaceReordered, EventKind::WorkspaceFocused, EventKind::WorktreeCreated, EventKind::WorktreeOpened, @@ -437,6 +443,12 @@ pub enum EventData { insert_index: usize, workspaces: Vec, }, + WorkspaceReordered { + workspace_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + before_workspace_id: Option, + workspaces: Vec, + }, WorkspaceFocused { workspace_id: String, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 9fcc03b0..16894dbf 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -469,6 +469,14 @@ fn event_envelope_round_trips() { workspaces: vec![], }, }, + EventEnvelope { + event: EventKind::WorkspaceReordered, + data: EventData::WorkspaceReordered { + workspace_ids: vec!["w_1".into(), "w_2".into()], + before_workspace_id: Some("w_3".into()), + workspaces: vec![], + }, + }, EventEnvelope { event: EventKind::TabMoved, data: EventData::TabMoved { @@ -1063,6 +1071,18 @@ fn authority_mutation_requests_round_trip() { let restored: Request = serde_json::from_value(json).unwrap(); assert_eq!(restored, workspace_move); + let workspace_move_block = Request { + id: "move_ws_block".into(), + method: Method::WorkspaceMoveBlock(WorkspaceMoveBlockParams { + workspace_ids: vec!["w1".into(), "w2".into()], + before_workspace_id: Some("w3".into()), + }), + }; + let json = serde_json::to_value(&workspace_move_block).unwrap(); + assert_eq!(json["method"], "workspace.move_block"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, workspace_move_block); + let tab_move = Request { id: "move_tab".into(), method: Method::TabMove(TabMoveParams { @@ -1105,6 +1125,7 @@ fn authority_mutation_requests_round_trip() { method: Method::EventsSubscribe(EventsSubscribeParams { subscriptions: vec![ Subscription::WorkspaceMoved {}, + Subscription::WorkspaceReordered {}, Subscription::TabMoved {}, Subscription::LayoutUpdated {}, ], @@ -1112,6 +1133,7 @@ fn authority_mutation_requests_round_trip() { }; let json = serde_json::to_string(&subscription).unwrap(); assert!(json.contains("\"type\":\"workspace.moved\"")); + assert!(json.contains("\"type\":\"workspace.reordered\"")); assert!(json.contains("\"type\":\"tab.moved\"")); assert!(json.contains("\"type\":\"layout.updated\"")); let restored: Request = serde_json::from_str(&json).unwrap(); diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs index a6177982..f9ad4ced 100644 --- a/src/api/schema/workspaces.rs +++ b/src/api/schema/workspaces.rs @@ -28,6 +28,13 @@ pub struct WorkspaceMoveParams { pub insert_index: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct WorkspaceMoveBlockParams { + pub workspace_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before_workspace_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct WorkspaceReportMetadataParams { pub workspace_id: String, diff --git a/src/api/server.rs b/src/api/server.rs index 5bc90ef9..0598faea 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -359,6 +359,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::WorkspaceFocus(_) => "workspace.focus", Method::WorkspaceRename(_) => "workspace.rename", Method::WorkspaceMove(_) => "workspace.move", + Method::WorkspaceMoveBlock(_) => "workspace.move_block", Method::WorkspaceReportMetadata(_) => "workspace.report_metadata", Method::WorkspaceClose(_) => "workspace.close", Method::WorktreeList(_) => "worktree.list", diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 13a340b6..7bcc7b77 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -132,6 +132,10 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::WorkspaceMoved, last_sequence: 0, })), + Subscription::WorkspaceReordered {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceReordered, + last_sequence: 0, + })), Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::WorkspaceClosed, last_sequence: 0, diff --git a/src/app/actions.rs b/src/app/actions.rs index 0709ed4c..10d28054 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1390,6 +1390,73 @@ impl AppState { true } + pub fn move_workspace_block( + &mut self, + workspace_ids: &[String], + before_workspace_id: Option<&str>, + ) -> bool { + let moved_ids = workspace_ids + .iter() + .map(String::as_str) + .collect::>(); + if moved_ids.is_empty() + || moved_ids.len() != workspace_ids.len() + || !workspace_ids + .iter() + .all(|id| self.workspaces.iter().any(|workspace| workspace.id == *id)) + || before_workspace_id.is_some_and(|id| { + moved_ids.contains(id) + || !self.workspaces.iter().any(|workspace| workspace.id == id) + }) + { + return false; + } + + let mut desired_ids = self + .workspaces + .iter() + .filter(|workspace| !moved_ids.contains(workspace.id.as_str())) + .map(|workspace| workspace.id.clone()) + .collect::>(); + let insert_idx = before_workspace_id + .and_then(|id| desired_ids.iter().position(|candidate| candidate == id)) + .unwrap_or(desired_ids.len()); + desired_ids.splice(insert_idx..insert_idx, workspace_ids.iter().cloned()); + if self + .workspaces + .iter() + .map(|workspace| workspace.id.as_str()) + .eq(desired_ids.iter().map(String::as_str)) + { + return false; + } + + let active_id = self.active.map(|idx| self.workspaces[idx].id.clone()); + let selected_id = self + .workspaces + .get(self.selected) + .map(|workspace| workspace.id.clone()); + let desired_positions = desired_ids + .iter() + .enumerate() + .map(|(index, id)| (id.clone(), index)) + .collect::>(); + + self.mark_session_dirty(); + self.workspaces.sort_by_key(|workspace| { + desired_positions + .get(&workspace.id) + .copied() + .unwrap_or(usize::MAX) + }); + self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)); + self.selected = selected_id + .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) + .unwrap_or(0); + self.ensure_workspace_visible(self.selected); + true + } + pub fn scroll_tabs_left(&mut self) { self.tab_scroll_follow_active = false; self.tab_scroll = self.tab_scroll.saturating_sub(1); @@ -4433,6 +4500,59 @@ mod tests { assert_eq!(names, vec!["b", "c", "a"]); } + #[test] + fn move_workspace_block_collects_non_contiguous_members() { + let mut state = + app_with_workspaces(&["child-one", "normal", "parent", "child-two", "tail"]); + let parent_id = state.workspaces[2].id.clone(); + let child_one_id = state.workspaces[0].id.clone(); + let child_two_id = state.workspaces[3].id.clone(); + let tail_id = state.workspaces[4].id.clone(); + state.active = Some(0); + state.selected = 4; + + assert!(state.move_workspace_block( + &[parent_id, child_one_id.clone(), child_two_id], + Some(&tail_id), + )); + + let names = state + .workspaces + .iter() + .map(|workspace| workspace.display_name()) + .collect::>(); + assert_eq!( + names, + ["normal", "parent", "child-one", "child-two", "tail"] + ); + assert_eq!(state.workspaces[state.active.unwrap()].id, child_one_id); + assert_eq!(state.workspaces[state.selected].id, tail_id); + } + + #[test] + fn move_workspace_block_rejects_invalid_and_noop_orders() { + let mut state = app_with_workspaces(&["a", "b", "c"]); + let ids = state + .workspaces + .iter() + .map(|workspace| workspace.id.clone()) + .collect::>(); + + assert!(!state.move_workspace_block(&[], None)); + assert!(!state.move_workspace_block(&[ids[0].clone(), ids[0].clone()], None)); + assert!(!state.move_workspace_block(&["missing".into()], None)); + assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[0]))); + assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[1]))); + assert_eq!( + state + .workspaces + .iter() + .map(|workspace| workspace.display_name()) + .collect::>(), + ["a", "b", "c"] + ); + } + #[test] fn close_workspace_adjusts_indices() { let mut state = app_with_workspaces(&["a", "b", "c"]); diff --git a/src/app/api.rs b/src/app/api.rs index 37b1f455..c46be676 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -988,6 +988,9 @@ impl App { Method::WorkspaceMove(params) => { return self.handle_workspace_move(request.id, params); } + Method::WorkspaceMoveBlock(params) => { + return self.handle_workspace_move_block(request.id, params); + } Method::WorkspaceReportMetadata(params) => { return self.handle_workspace_report_metadata(request.id, params); } diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index 16d2fdb1..efef83b9 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -63,6 +63,12 @@ impl App { context }) }), + EventData::WorkspaceReordered { workspace_ids, .. } => workspace_ids + .first() + .and_then(|workspace_id| { + self.plugin_context_for_workspace_id(workspace_id, correlation_id) + }) + .unwrap_or_else(|| empty_plugin_context(correlation_id)), EventData::WorkspaceRenamed { workspace_id, .. } | EventData::WorkspaceMoved { workspace_id, .. } | EventData::WorkspaceFocused { workspace_id } => self diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index b7260c17..cf50d3ad 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -2,7 +2,8 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, - WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, WorkspaceTarget, + WorkspaceMoveBlockParams, WorkspaceMoveParams, WorkspaceRenameParams, + WorkspaceReportMetadataParams, WorkspaceTarget, }; use crate::app::App; @@ -154,6 +155,76 @@ impl App { encode_success(id, ResponseResult::WorkspaceList { workspaces }) } + pub(super) fn handle_workspace_move_block( + &mut self, + id: String, + params: WorkspaceMoveBlockParams, + ) -> String { + if params.workspace_ids.is_empty() { + return encode_error( + id, + "workspace_move_block_failed", + "workspace_ids must not be empty", + ); + } + + let mut workspace_ids = Vec::with_capacity(params.workspace_ids.len()); + let mut seen_ids = std::collections::HashSet::new(); + for requested_id in ¶ms.workspace_ids { + let Some(index) = self.parse_workspace_id(requested_id) else { + return workspace_not_found(id, requested_id); + }; + let Some(workspace) = self.state.workspaces.get(index) else { + return workspace_not_found(id, requested_id); + }; + if !seen_ids.insert(workspace.id.clone()) { + return encode_error( + id, + "workspace_move_block_failed", + format!("workspace {requested_id} appears more than once"), + ); + } + workspace_ids.push(workspace.id.clone()); + } + + let before_workspace_id = match params.before_workspace_id { + Some(requested_id) => { + let Some(index) = self.parse_workspace_id(&requested_id) else { + return workspace_not_found(id, &requested_id); + }; + let Some(workspace) = self.state.workspaces.get(index) else { + return workspace_not_found(id, &requested_id); + }; + if seen_ids.contains(&workspace.id) { + return encode_error( + id, + "workspace_move_block_failed", + "before_workspace_id must not be part of workspace_ids", + ); + } + Some(workspace.id.clone()) + } + None => None, + }; + + let moved = self + .state + .move_workspace_block(&workspace_ids, before_workspace_id.as_deref()); + let workspaces = self.workspace_list_info(); + if moved { + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceReordered, + data: EventData::WorkspaceReordered { + workspace_ids, + before_workspace_id, + workspaces: workspaces.clone(), + }, + }); + } + + encode_success(id, ResponseResult::WorkspaceList { workspaces }) + } + pub(super) fn handle_workspace_report_metadata( &mut self, id: String, @@ -560,6 +631,59 @@ mod tests { })); } + #[test] + fn api_workspace_move_block_reorders_atomically() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![ + Workspace::test_new("child"), + Workspace::test_new("normal"), + Workspace::test_new("parent"), + Workspace::test_new("tail"), + ]; + let parent_id = app.public_workspace_id(2); + let child_id = app.public_workspace_id(0); + let tail_id = app.public_workspace_id(3); + + let response = app.handle_workspace_move_block( + "req".into(), + WorkspaceMoveBlockParams { + workspace_ids: vec![parent_id.clone(), child_id.clone()], + before_workspace_id: Some(tail_id.clone()), + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::WorkspaceList { workspaces } = success.result else { + panic!("expected workspace list"); + }; + assert_eq!( + app.state + .workspaces + .iter() + .map(|workspace| workspace.display_name()) + .collect::>(), + ["normal", "parent", "child", "tail"] + ); + assert_eq!(workspaces[1].workspace_id, parent_id); + assert_eq!(workspaces[2].workspace_id, child_id); + let events = event_hub.events_after(0); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0].1.data, + EventData::WorkspaceReordered { + workspace_ids, + before_workspace_id, + workspaces, + } if workspace_ids.first() == Some(&parent_id) + && workspace_ids.get(1) == Some(&child_id) + && workspace_ids.len() == 2 + && before_workspace_id.as_deref() == Some(tail_id.as_str()) + && workspaces[1].workspace_id == parent_id + )); + } + #[test] fn api_workspace_move_noop_does_not_emit_event() { let event_hub = crate::api::EventHub::default(); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 3d20adcd..570e8b8c 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -366,6 +366,9 @@ impl App { source_ws_idx, insert_idx, } => self.move_workspace_via_api(source_ws_idx, insert_idx), + MouseAction::MoveWorkspaceBlock { params } => { + self.move_workspace_block_via_api(params) + } MouseAction::MoveTab { ws_idx, source_tab_idx, diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index e0f89387..56b9b676 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -43,6 +43,9 @@ pub(super) enum MouseAction { source_ws_idx: usize, insert_idx: usize, }, + MoveWorkspaceBlock { + params: crate::api::schema::WorkspaceMoveBlockParams, + }, MoveTab { ws_idx: usize, source_tab_idx: usize, @@ -561,9 +564,8 @@ impl AppState { self.view.workspace_card_areas.clone() }; if let Some(card) = cards.iter().find(|card| { - mouse.row == card.rect.y - && mouse.column == card.rect.x - && mouse.column < card.rect.x + card.rect.width + let chevron = crate::ui::workspace_group_chevron_rect(card); + mouse.row == chevron.y && mouse.column == chevron.x && chevron.width > 0 }) { if let Some((key, collapsed)) = crate::ui::workspace_parent_group_state(self, card.ws_idx) @@ -671,21 +673,21 @@ impl AppState { } } - let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row); + let workspace_drop_target = self.workspace_drop_target_at_row(mouse.row); let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); if self.drag.is_none() { if let Some(press) = &self.workspace_press { let delta_col = mouse.column.abs_diff(press.start_col); let delta_row = mouse.row.abs_diff(press.start_row); - let can_reorder = self - .workspaces - .get(press.ws_idx) - .is_some_and(|ws| ws.worktree_space().is_none()); + let can_reorder = self.workspaces.get(press.ws_idx).is_some_and(|ws| { + ws.worktree_space() + .is_none_or(|space| !space.is_linked_worktree) + }); if can_reorder && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { self.drag = Some(DragState { target: DragTarget::WorkspaceReorder { source_ws_idx: press.ws_idx, - insert_idx: workspace_drop_index, + drop_target: workspace_drop_target, }, }); } @@ -705,10 +707,10 @@ impl AppState { } if let Some(DragState { - target: DragTarget::WorkspaceReorder { insert_idx, .. }, + target: DragTarget::WorkspaceReorder { drop_target, .. }, }) = &mut self.drag { - *insert_idx = workspace_drop_index; + *drop_target = workspace_drop_target; } else if let Some(DragState { target: DragTarget::TabReorder { @@ -837,13 +839,33 @@ impl AppState { target: DragTarget::WorkspaceReorder { source_ws_idx, - insert_idx: Some(insert_idx), + drop_target: Some(drop_target), }, }) => { - return Some(MouseAction::MoveWorkspace { - source_ws_idx, - insert_idx, - }); + if let Some(params) = + self.workspace_move_block_params(source_ws_idx, drop_target) + { + if self + .workspaces + .get(source_ws_idx) + .is_some_and(|workspace| workspace.worktree_space().is_some()) + { + return Some(MouseAction::MoveWorkspaceBlock { params }); + } + let insert_idx = params + .before_workspace_id + .as_ref() + .and_then(|id| { + self.workspaces + .iter() + .position(|workspace| workspace.id == *id) + }) + .unwrap_or(self.workspaces.len()); + return Some(MouseAction::MoveWorkspace { + source_ws_idx, + insert_idx, + }); + } } Some(DragState { target: diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index d151b94d..a5300461 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -434,6 +434,13 @@ impl App { ); } + pub(crate) fn move_workspace_block_via_api( + &mut self, + params: crate::api::schema::WorkspaceMoveBlockParams, + ) { + self.runtime_workspace_move_block("tui.workspace.move_block", params); + } + pub(crate) fn focus_tab_idx_via_api(&mut self, tab_idx: usize) { let Some(ws_idx) = self.state.active else { return; diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 85de8aac..1c3a3b58 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -358,7 +358,10 @@ impl AppState { Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) } - pub(super) fn workspace_drop_index_at_row(&self, row: u16) -> Option { + pub(super) fn workspace_drop_target_at_row( + &self, + row: u16, + ) -> Option { let area = self.workspace_list_rect(); let footer = self.sidebar_footer_rect(); if area == Rect::default() || row < area.y || row >= footer.y { @@ -370,46 +373,96 @@ impl AppState { } else { self.view.workspace_card_areas.clone() }; - if cards.is_empty() { - return Some(0); + crate::ui::workspace_drop_slots(self, &cards, area) + .into_iter() + .enumerate() + .min_by_key(|(slot_idx, (_, slot_row))| (row.abs_diff(*slot_row), *slot_idx)) + .map(|(_, (target, _))| target) + } + + pub(super) fn workspace_move_block_params( + &self, + source_ws_idx: usize, + drop_target: crate::app::state::WorkspaceDropTarget, + ) -> Option { + let source = self.workspaces.get(source_ws_idx)?; + if source + .worktree_space() + .is_some_and(|space| space.is_linked_worktree) + { + return None; } - let mut insert_indices = Vec::with_capacity(cards.len() + 1); - for (idx, card) in cards.iter().enumerate() { - let card_group = self - .workspaces - .get(card.ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.as_str()); - let previous_group = idx.checked_sub(1).and_then(|prev_idx| { - self.workspaces - .get(cards[prev_idx].ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.as_str()) - }); - let inside_group_gap = card_group.is_some() && card_group == previous_group; - if !inside_group_gap { - insert_indices.push(card.ws_idx); + let roots = crate::ui::workspace_list_entries_expanded(self) + .into_iter() + .filter_map(|entry| match entry { + crate::ui::WorkspaceListEntry::Workspace { + ws_idx, + indented: false, + } => Some(ws_idx), + crate::ui::WorkspaceListEntry::Workspace { .. } => None, + }) + .collect::>(); + let source_pos = roots.iter().position(|ws_idx| *ws_idx == source_ws_idx)?; + let remaining_roots = roots + .iter() + .copied() + .filter(|ws_idx| *ws_idx != source_ws_idx) + .collect::>(); + let insert_pos = match drop_target { + crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => remaining_roots + .iter() + .position(|ws_idx| *ws_idx == target_ws_idx)?, + crate::app::state::WorkspaceDropTarget::End => remaining_roots.len(), + }; + if insert_pos == source_pos { + return None; + } + + let workspace_ids = match source.worktree_space() { + Some(source_space) => { + let mut ids = vec![source.id.clone()]; + ids.extend( + self.workspaces + .iter() + .filter(|workspace| workspace.id != source.id) + .filter(|workspace| { + workspace + .worktree_space() + .is_some_and(|space| space.key == source_space.key) + }) + .map(|workspace| workspace.id.clone()), + ); + ids } - } - insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0)); - - let mut best: Option<(usize, u16)> = None; - for insert_idx in insert_indices { - let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx) - else { - continue; - }; - let distance = row.abs_diff(slot_row); - match best { - Some((best_idx, best_distance)) - if distance > best_distance - || (distance == best_distance && insert_idx < best_idx) => {} - _ => best = Some((insert_idx, distance)), + None => vec![source.id.clone()], + }; + let before_workspace_id = match drop_target { + crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => { + let target = self.workspaces.get(target_ws_idx)?; + let anchor = match crate::ui::workspace_parent_group_state(self, target_ws_idx) + .and_then(|_| target.worktree_space()) + { + Some(target_space) => self + .workspaces + .iter() + .find(|workspace| { + workspace + .worktree_space() + .is_some_and(|space| space.key == target_space.key) + }) + .unwrap_or(target), + None => target, + }; + Some(anchor.id.clone()) } - } + crate::app::state::WorkspaceDropTarget::End => None, + }; - best.map(|(insert_idx, _)| insert_idx) + Some(crate::api::schema::WorkspaceMoveBlockParams { + workspace_ids, + before_workspace_id, + }) } pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool { @@ -1209,12 +1262,13 @@ mod tests { app.state.active = None; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0].rect; + let parent = app.state.view.workspace_card_areas[0]; + let chevron = crate::ui::workspace_group_chevron_rect(&parent); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), - parent.x, - parent.y, + chevron.x, + chevron.y, )); assert_eq!(app.state.active, None); @@ -1223,8 +1277,8 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), - parent.x, - parent.y, + chevron.x, + chevron.y, )); assert!(!app.state.collapsed_space_keys.contains("repo-key")); @@ -1279,15 +1333,16 @@ mod tests { crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let packed_boundary_row = app.state.view.workspace_card_areas[1].rect.y; assert_eq!( - app.state.workspace_drop_index_at_row(packed_boundary_row), - Some(2) + app.state.workspace_drop_target_at_row(packed_boundary_row), + Some(crate::app::state::WorkspaceDropTarget::Before(2)) ); let source_row = app.state.view.workspace_card_areas[1].rect.y; let target_row = crate::ui::workspace_drop_indicator_row( + &app.state, &app.state.view.workspace_card_areas, app.state.workspace_list_rect(), - 0, + crate::app::state::WorkspaceDropTarget::Before(0), ) .unwrap(); @@ -1305,7 +1360,7 @@ mod tests { app.state.drag.as_ref().map(|drag| &drag.target), Some(DragTarget::WorkspaceReorder { source_ws_idx: 1, - insert_idx: Some(0), + drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(0)), }) )); app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); @@ -1321,6 +1376,15 @@ mod tests { assert_eq!(app.state.selected, 2); assert_eq!(app.state.workspaces[0].id, active_id); assert_eq!(app.state.workspaces[2].id, selected_id); + let events = app.event_hub.events_after(0); + assert!(events.iter().any(|(_, event)| matches!( + event.data, + crate::api::schema::EventData::WorkspaceMoved { .. } + ))); + assert!(!events.iter().any(|(_, event)| matches!( + event.data, + crate::api::schema::EventData::WorkspaceReordered { .. } + ))); let snapshot = capture_snapshot(&app.state); let captured_names: Vec<_> = snapshot .workspaces @@ -1516,10 +1580,22 @@ mod tests { app.state.sidebar_spaces.row_gap = 1; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1)); + assert_eq!( + app.state.workspace_drop_target_at_row(0), + Some(crate::app::state::WorkspaceDropTarget::Before(0)) + ); + assert_eq!( + app.state.workspace_drop_target_at_row(1), + Some(crate::app::state::WorkspaceDropTarget::Before(0)) + ); + assert_eq!( + app.state.workspace_drop_target_at_row(2), + Some(crate::app::state::WorkspaceDropTarget::Before(0)) + ); + assert_eq!( + app.state.workspace_drop_target_at_row(3), + Some(crate::app::state::WorkspaceDropTarget::Before(1)) + ); let _ = fs::remove_dir_all(first_repo); let _ = fs::remove_dir_all(second_repo); @@ -1537,9 +1613,10 @@ mod tests { let cards = &app.state.view.workspace_card_areas; let bottom_slot = crate::ui::workspace_drop_indicator_row( + &app.state, cards, app.state.workspace_list_rect(), - cards.len(), + crate::app::state::WorkspaceDropTarget::End, ) .unwrap(); @@ -1566,13 +1643,151 @@ mod tests { let issue = cards.iter().find(|card| card.ws_idx == 2).unwrap(); let normal = cards.iter().find(|card| card.ws_idx == 1).unwrap(); - assert_eq!(app.state.workspace_drop_index_at_row(issue.rect.y), Some(1)); assert_eq!( - crate::ui::workspace_drop_indicator_row(cards, app.state.workspace_list_rect(), 2), + app.state.workspace_drop_target_at_row(issue.rect.y), + Some(crate::app::state::WorkspaceDropTarget::Before(1)) + ); + assert_eq!( + crate::ui::workspace_drop_indicator_row( + &app.state, + cards, + app.state.workspace_list_rect(), + crate::app::state::WorkspaceDropTarget::End, + ), Some(normal.rect.y + normal.rect.height) ); } + #[test] + fn plain_drag_anchors_to_the_selected_parentless_linked_workspace() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + workspace_with_space("one", "repo-key"), + workspace_with_space("two", "repo-key"), + Workspace::test_new("normal"), + ]; + let target_id = app.state.workspaces[1].id.clone(); + + let params = app + .state + .workspace_move_block_params(2, crate::app::state::WorkspaceDropTarget::Before(1)) + .unwrap(); + + assert_eq!(params.workspace_ids, [app.state.workspaces[2].id.clone()]); + assert_eq!( + params.before_workspace_id.as_deref(), + Some(target_id.as_str()) + ); + } + + #[test] + fn dragging_worktree_parent_reorders_the_complete_group() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + workspace_with_space("main", "repo-key"), + Workspace::test_new("normal"), + workspace_with_space("issue", "repo-key"), + ]; + app.state.active = Some(2); + app.state.selected = 1; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); + + let parent = app + .state + .view + .workspace_card_areas + .iter() + .find(|card| card.ws_idx == 0) + .unwrap() + .rect; + let target_row = crate::ui::workspace_drop_indicator_row( + &app.state, + &app.state.view.workspace_card_areas, + app.state.workspace_list_rect(), + crate::app::state::WorkspaceDropTarget::End, + ) + .unwrap(); + let active_id = app.state.workspaces[2].id.clone(); + let selected_id = app.state.workspaces[1].id.clone(); + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + 2, + target_row, + )); + assert!(matches!( + app.state.drag.as_ref().map(|drag| &drag.target), + Some(DragTarget::WorkspaceReorder { + source_ws_idx: 0, + drop_target: Some(crate::app::state::WorkspaceDropTarget::End), + }) + )); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); + + assert_eq!( + app.state + .workspaces + .iter() + .map(|workspace| workspace.display_name()) + .collect::>(), + ["normal", "main", "issue"] + ); + assert_eq!( + app.state.workspaces[app.state.active.unwrap()].id, + active_id + ); + assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); + } + + #[test] + fn dragging_collapsed_worktree_parent_still_moves_hidden_children() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + workspace_with_space("issue", "repo-key"), + Workspace::test_new("normal"), + workspace_with_space("main", "repo-key"), + workspace_with_space("review", "repo-key"), + ]; + app.state.active = Some(0); + app.state.selected = 1; + app.state.collapsed_space_keys.insert("repo-key".into()); + let active_id = app.state.workspaces[0].id.clone(); + let selected_id = app.state.workspaces[1].id.clone(); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); + assert_eq!(app.state.view.workspace_card_areas.len(), 3); + + let parent = app.state.view.workspace_card_areas[0].rect; + let target_row = crate::ui::workspace_drop_indicator_row( + &app.state, + &app.state.view.workspace_card_areas, + app.state.workspace_list_rect(), + crate::app::state::WorkspaceDropTarget::End, + ) + .unwrap(); + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + 2, + target_row, + )); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); + + assert_eq!( + app.state + .workspaces + .iter() + .map(|workspace| workspace.display_name()) + .collect::>(), + ["normal", "main", "issue", "review"] + ); + assert_eq!( + app.state.workspaces[app.state.active.unwrap()].id, + active_id + ); + assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); + } + #[test] fn dragging_worktree_space_member_does_not_reorder_workspaces() { let mut app = app_for_mouse_test(); @@ -1594,9 +1809,10 @@ mod tests { .unwrap() .rect; let target_row = crate::ui::workspace_drop_indicator_row( + &app.state, &app.state.view.workspace_card_areas, app.state.workspace_list_rect(), - 0, + crate::app::state::WorkspaceDropTarget::Before(0), ) .unwrap(); diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs index 77cd1f10..f765bf20 100644 --- a/src/app/runtime_mutations.rs +++ b/src/app/runtime_mutations.rs @@ -1,9 +1,9 @@ use crate::api::schema::{ EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomParams, TabCreateParams, - TabMoveParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceMoveParams, - WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams, - WorktreeRemoveParams, + TabMoveParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceMoveBlockParams, + WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, + WorktreeOpenParams, WorktreeRemoveParams, }; use super::App; @@ -53,6 +53,14 @@ impl App { self.dispatch_runtime_mutation(id, Method::WorkspaceMove(params)) } + pub(crate) fn runtime_workspace_move_block( + &mut self, + id: &'static str, + params: WorkspaceMoveBlockParams, + ) -> String { + self.dispatch_runtime_mutation(id, Method::WorkspaceMoveBlock(params)) + } + pub(crate) fn runtime_workspace_close( &mut self, id: &'static str, diff --git a/src/app/state.rs b/src/app/state.rs index 6069b8a6..17b3ae4c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1141,10 +1141,16 @@ pub struct SettingsState { pub original_theme: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkspaceDropTarget { + Before(usize), + End, +} + pub(crate) enum DragTarget { WorkspaceReorder { source_ws_idx: usize, - insert_idx: Option, + drop_target: Option, }, TabReorder { ws_idx: usize, @@ -2211,16 +2217,11 @@ impl AppState { match &drag.target { DragTarget::WorkspaceReorder { source_ws_idx, - insert_idx, + drop_target, } => { assert_workspace_index(*source_ws_idx, "workspace drag source"); - if let Some(insert_idx) = insert_idx { - assert!( - *insert_idx <= self.workspaces.len(), - "workspace drag insert index {} out of bounds for {} workspaces", - insert_idx, - self.workspaces.len() - ); + if let Some(WorkspaceDropTarget::Before(ws_idx)) = drop_target { + assert_workspace_index(*ws_idx, "workspace drag target"); } } DragTarget::TabReorder { diff --git a/src/ui.rs b/src/ui.rs index ed1f6a7d..59145dd2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -52,6 +52,8 @@ pub(crate) use self::scrollbar::{ scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar, }; use self::settings::render_settings_overlay; +#[cfg(test)] +pub(crate) use self::sidebar::workspace_drop_indicator_row; use self::sidebar::{render_sidebar, render_sidebar_collapsed}; use self::status::{ copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_toast_notification, @@ -79,11 +81,13 @@ pub(crate) use self::{ agent_panel_toggle_rect, all_agent_panel_entries, collapsed_sidebar_sections, collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, expanded_sidebar_toggle_rect, normalized_workspace_scroll, sidebar_section_divider_rect, - workspace_drop_indicator_row, workspace_list_entries, workspace_list_entries_expanded, - workspace_list_rect, workspace_list_scroll_metrics, workspace_list_scrollbar_rect, - workspace_parent_group_state, AgentPanelEntry, WorkspaceListEntry, + workspace_drop_slots, workspace_group_chevron_rect, workspace_list_entries, + workspace_list_entries_expanded, workspace_list_rect, workspace_list_scroll_metrics, + workspace_list_scrollbar_rect, workspace_parent_group_state, AgentPanelEntry, + WorkspaceListEntry, }, }; + pub(crate) use self::{ keybind_help::keybind_help_lines, mobile::{ diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index c7da990a..09078c17 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -718,6 +718,19 @@ pub(crate) fn compute_workspace_card_areas( compute_workspace_list_areas(app, area).0 } +pub(crate) fn workspace_group_chevron_rect(card: &crate::app::state::WorkspaceCardArea) -> Rect { + if card.rect.width == 0 || card.rect.height == 0 { + return Rect::default(); + } + + Rect::new( + card.rect.x + card.rect.width.saturating_sub(1), + card.rect.y, + 1, + 1, + ) +} + /// Auto-scale sidebar width based on workspace identity + agent summary. pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); @@ -851,35 +864,100 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: render_sidebar_toggle(app, frame, area, true, p); } -pub(crate) fn workspace_drop_indicator_row( +pub(crate) fn workspace_drop_slots( + app: &AppState, cards: &[crate::app::state::WorkspaceCardArea], area: Rect, - insert_idx: usize, -) -> Option { - if area.height == 0 { - return None; +) -> Vec<(crate::app::state::WorkspaceDropTarget, u16)> { + if area.height == 0 || cards.is_empty() { + return Vec::new(); } let list_bottom = area.y + area.height.saturating_sub(1); + let entries = workspace_list_entries(app); + let entry_position = |ws_idx| { + entries.iter().position(|entry| { + matches!( + entry, + WorkspaceListEntry::Workspace { + ws_idx: entry_ws_idx, + .. + } if *entry_ws_idx == ws_idx + ) + }) + }; + let block_root_at = |entry_idx: usize| { + entries[..=entry_idx] + .iter() + .rev() + .find_map(|entry| match entry { + WorkspaceListEntry::Workspace { + ws_idx, + indented: false, + } => Some(*ws_idx), + WorkspaceListEntry::Workspace { .. } => None, + }) + }; - let first = cards.first()?; - if insert_idx == first.ws_idx { - return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom); + let mut slots = Vec::new(); + let mut previous_root = None; + for card in cards { + let Some(entry_idx) = entry_position(card.ws_idx) else { + continue; + }; + let Some(root_idx) = block_root_at(entry_idx) else { + continue; + }; + if previous_root == Some(root_idx) { + continue; + } + previous_root = Some(root_idx); + if let Some(row) = card.rect.y.checked_sub(1).filter(|row| *row < list_bottom) { + slots.push(( + crate::app::state::WorkspaceDropTarget::Before(root_idx), + row, + )); + } } - if let Some(row) = cards - .last() - .filter(|card| insert_idx == card.ws_idx.saturating_add(1)) - .map(|card| card.rect.y.saturating_add(card.rect.height)) - .filter(|y| *y < list_bottom) + let Some(last) = cards.last() else { + return slots; + }; + let Some(last_entry_idx) = entry_position(last.ws_idx) else { + return slots; + }; + let next_entry = entries.get(last_entry_idx.saturating_add(1)); + if matches!( + next_entry, + Some(WorkspaceListEntry::Workspace { indented: true, .. }) + ) { + return slots; + } + let target = match next_entry { + Some(WorkspaceListEntry::Workspace { ws_idx, .. }) => { + crate::app::state::WorkspaceDropTarget::Before(*ws_idx) + } + None => crate::app::state::WorkspaceDropTarget::End, + }; + let row = last.rect.y.saturating_add(last.rect.height); + if row < list_bottom + && slots + .last() + .is_none_or(|(last_target, _)| *last_target != target) { - return Some(row); + slots.push((target, row)); } + slots +} - if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) { - return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom); - } - - None +pub(crate) fn workspace_drop_indicator_row( + app: &AppState, + cards: &[crate::app::state::WorkspaceCardArea], + area: Rect, + target: crate::app::state::WorkspaceDropTarget, +) -> Option { + workspace_drop_slots(app, cards, area) + .into_iter() + .find_map(|(candidate, row)| (candidate == target).then_some(row)) } pub(super) fn render_sidebar( @@ -1123,9 +1201,9 @@ fn render_workspace_list( }; let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) { Some(crate::app::state::DragTarget::WorkspaceReorder { - insert_idx: Some(insert_idx), + drop_target: Some(drop_target), .. - }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx), + }) => workspace_drop_indicator_row(app, &app.view.workspace_card_areas, area, *drop_target), _ => None, }; @@ -1143,6 +1221,7 @@ fn render_workspace_list( let metrics = workspace_list_scroll_metrics(app, area); let scrollbar_rect = workspace_list_scrollbar_rect(app, area); let cards = &app.view.workspace_card_areas; + let entries = workspace_list_entries(app); for card in cards { let i = card.ws_idx; @@ -1189,6 +1268,16 @@ fn render_workspace_list( let parent_group = (!card.indented) .then(|| workspace_parent_group_state(app, i)) .flatten(); + let is_last_child = card.indented + && entries + .iter() + .position(|entry| { + matches!( + entry, + WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == i + ) + }) + .is_none_or(|entry_idx| !next_entry_is_indented_workspace(&entries, entry_idx)); let (display_state, display_seen) = parent_group .as_ref() .filter(|(_, collapsed)| *collapsed) @@ -1221,34 +1310,34 @@ fn render_workspace_list( break; } let mut spans = Vec::new(); - if row_index == 0 { - if card.indented { - spans.push(Span::raw(" ")); - } else if let Some((_, collapsed)) = parent_group.as_ref() { + let prefix_width = if card.indented { + spans.push(Span::raw(" ")); + if row_index == 0 { spans.push(Span::styled( - if *collapsed { "▸" } else { "▾" }, - Style::default().fg(p.accent), + if is_last_child { "└─ " } else { "├─ " }, + Style::default().fg(p.overlay0), )); - spans.push(Span::raw(" ")); + 6 + } else if is_last_child { + spans.push(Span::raw(" ")); + 8 } else { - spans.push(Span::raw(" ")); + spans.push(Span::styled("│", Style::default().fg(p.overlay0))); + spans.push(Span::raw(" ")); + 8 } + } else if row_index == 0 { + spans.push(Span::raw(" ")); + 1 } else { - spans.push(Span::raw(if card.indented { " " } else { " " })); - } - let prefix_width = if row_index == 0 { - if card.indented { - 3 - } else if parent_group.is_some() { - 2 - } else { - 1 - } - } else if card.indented { - 5 - } else { + spans.push(Span::raw(" ")); 3 }; + let trailing_width = if row_index == 0 && parent_group.is_some() { + 2 + } else { + 0 + }; spans.extend(resolved_token_spans( resolved, state_icon, @@ -1257,13 +1346,25 @@ fn render_workspace_list( branch_style, branch_style, p, - card.rect.width.saturating_sub(prefix_width) as usize, + card.rect + .width + .saturating_sub(prefix_width + trailing_width) as usize, )); frame.render_widget( Paragraph::new(Line::from(spans)), Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1), ); } + + if let Some((_, collapsed)) = parent_group { + frame.render_widget( + Paragraph::new(Span::styled( + if collapsed { "▸" } else { "▾" }, + Style::default().fg(p.accent), + )), + workspace_group_chevron_rect(card), + ); + } } if let Some(y) = insertion_row.filter(|y| *y < list_bottom) { @@ -2292,6 +2393,85 @@ rows = [[{ token = "git_status", fg = "#123456" }]] ws } + #[test] + fn desktop_worktree_tree_aligns_parents_and_marks_children() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), + Workspace::test_new("notes"), + ]; + app.sidebar_spaces.rows = vec![vec![ + crate::config::SpaceSidebarToken::StateIcon, + crate::config::SpaceSidebarToken::Workspace, + ]]; + app.sidebar_spaces.row_gap = 0; + let area = Rect::new(0, 0, 30, 20); + app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); + let list_area = workspace_list_rect(area, app.sidebar_section_split); + + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal + .draw(|frame| { + render_workspace_list( + &app, + &TerminalRuntimeRegistry::new(), + frame, + list_area, + false, + ) + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let cards = &app.view.workspace_card_areas; + let parent_name_x = find_symbol_x(buffer, cards[0].rect.y, cards[0].rect.width, "m"); + let plain_name_x = find_symbol_x(buffer, cards[3].rect.y, cards[3].rect.width, "n"); + assert_eq!(parent_name_x, plain_name_x); + assert_eq!(buffer[(cards[1].rect.x + 3, cards[1].rect.y)].symbol(), "├"); + assert_eq!(buffer[(cards[2].rect.x + 3, cards[2].rect.y)].symbol(), "└"); + assert_eq!( + buffer[(cards[0].rect.x + cards[0].rect.width - 1, cards[0].rect.y)].symbol(), + "▾" + ); + } + + #[test] + fn desktop_worktree_connector_uses_full_list_at_viewport_boundary() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), + ]; + app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + app.sidebar_spaces.row_gap = 0; + let area = Rect::new(0, 0, 30, 10); + app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); + assert_eq!(app.view.workspace_card_areas.len(), 2); + let list_area = workspace_list_rect(area, app.sidebar_section_split); + + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal + .draw(|frame| { + render_workspace_list( + &app, + &TerminalRuntimeRegistry::new(), + frame, + list_area, + false, + ) + }) + .unwrap(); + + let child = app.view.workspace_card_areas[1]; + assert_eq!( + terminal.backend().buffer()[(child.rect.x + 3, child.rect.y)].symbol(), + "├" + ); + } + #[test] fn parent_workspace_row_stays_clickable_when_grouped() { let mut app = AppState::test_new(); @@ -2363,13 +2543,18 @@ rows = [[{ token = "git_status", fg = "#123456" }]] let area = Rect::new(0, 0, 30, 20); app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); let list_area = workspace_list_rect(area, app.sidebar_section_split); - let indicator_row = - workspace_drop_indicator_row(&app.view.workspace_card_areas, list_area, 2).unwrap(); + let indicator_row = workspace_drop_indicator_row( + &app, + &app.view.workspace_card_areas, + list_area, + crate::app::state::WorkspaceDropTarget::Before(2), + ) + .unwrap(); assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y); app.drag = Some(crate::app::state::DragState { target: crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx: 0, - insert_idx: Some(2), + drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(2)), }, });