From 868b705cc25489a49729035217602655c9a952c1 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 19:34:57 +0000 Subject: [PATCH 01/25] db: add archived workspace timestamp --- .../20260721103000_add_workspace_archived_at.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 crates/db/migrations/20260721103000_add_workspace_archived_at.sql diff --git a/crates/db/migrations/20260721103000_add_workspace_archived_at.sql b/crates/db/migrations/20260721103000_add_workspace_archived_at.sql new file mode 100644 index 0000000000..5c11c0eb6f --- /dev/null +++ b/crates/db/migrations/20260721103000_add_workspace_archived_at.sql @@ -0,0 +1,8 @@ +ALTER TABLE workspaces ADD COLUMN archived_at DATETIME; + +-- Existing archived workspaces predate an explicit archive timestamp. `updated_at` +-- is the best available one-time proxy because every workspace write, including +-- archive toggles, updates it; for untouched archived rows it is the archive time. +UPDATE workspaces +SET archived_at = updated_at +WHERE archived = TRUE; From 64bcb4ac178bd6cedbc60fcd5cf03f54c7269c66 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 19:41:16 +0000 Subject: [PATCH 02/25] db: track workspace archive transitions --- ...5e29ab9039cbdb4fd2144601b152b98ac3fd0.json | 110 ++++++++++++ ...e75fe336537063a13e8ce39a16a1f6953da69.json | 12 ++ ...b5c2f455e77c40c624e09736be9480238110b.json | 12 -- ...a67163af6484f91954c0577aea1cc2fddac4f.json | 12 -- ...40417843e0ee975b19864f78fdfae6be03cc.json} | 16 +- ...a522fab0156fac78a2216851e6e70eb68352a.json | 110 ++++++++++++ ...9baf195ff5e112b2e1d83063b51043060a27.json} | 16 +- ...ff9759a0265755a93ce1f602a6bcf71d4f586.json | 104 ----------- ...f1206ecafdc960b3147613ff8c68e988921f.json} | 16 +- ...2cadc07ad23b839f983cf82ed6df5fc20e3b.json} | 16 +- ...f993acba8bef194cda58722854394e84d39bb.json | 12 ++ ...dde3df496e13a80067ecb9a58996018ab9f12.json | 104 ----------- ...c30f2d0fa7b607c447cb0a8789a9c5442854.json} | 16 +- crates/db/src/models/workspace.rs | 168 +++++++++++++++++- shared/types.ts | 4 +- 15 files changed, 466 insertions(+), 262 deletions(-) create mode 100644 crates/db/.sqlx/query-2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0.json create mode 100644 crates/db/.sqlx/query-42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69.json delete mode 100644 crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json delete mode 100644 crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json rename crates/db/.sqlx/{query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json => query-57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc.json} (77%) create mode 100644 crates/db/.sqlx/query-6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a.json rename crates/db/.sqlx/{query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json => query-85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27.json} (79%) delete mode 100644 crates/db/.sqlx/query-a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586.json rename crates/db/.sqlx/{query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json => query-a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f.json} (79%) rename crates/db/.sqlx/{query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json => query-c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b.json} (82%) create mode 100644 crates/db/.sqlx/query-d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb.json delete mode 100644 crates/db/.sqlx/query-e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12.json rename crates/db/.sqlx/{query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json => query-f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854.json} (52%) diff --git a/crates/db/.sqlx/query-2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0.json b/crates/db/.sqlx/query-2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0.json new file mode 100644 index 0000000000..b9853d5625 --- /dev/null +++ b/crates/db/.sqlx/query-2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.archived_at AS \"archived_at: DateTime\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n WHERE w.id = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_id: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "container_ref", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "branch", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "setup_completed_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "archived!: bool", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "archived_at: DateTime", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "worktree_deleted!: bool", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "is_running!: i64", + "ordinal": 12, + "type_info": "Null" + }, + { + "name": "is_executor_running!: i64", + "ordinal": 13, + "type_info": "Null" + }, + { + "name": "is_coding_agent_running!: i64", + "ordinal": 14, + "type_info": "Null" + }, + { + "name": "is_errored!: i64", + "ordinal": 15, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + null, + null, + null, + null + ] + }, + "hash": "2b859252c7ffc02915f0ffe74345e29ab9039cbdb4fd2144601b152b98ac3fd0" +} diff --git a/crates/db/.sqlx/query-42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69.json b/crates/db/.sqlx/query-42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69.json new file mode 100644 index 0000000000..e0f07b37b3 --- /dev/null +++ b/crates/db/.sqlx/query-42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE workspaces SET\n archived = $1,\n archived_at = CASE\n WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec')\n WHEN $1 = FALSE THEN NULL\n ELSE archived_at\n END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "42c452ed4370a6c3403b7e16b25e75fe336537063a13e8ce39a16a1f6953da69" +} diff --git a/crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json b/crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json deleted file mode 100644 index 827c9f25fb..0000000000 --- a/crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b" -} diff --git a/crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json b/crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json deleted file mode 100644 index d016685e4e..0000000000 --- a/crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE workspaces SET\n archived = COALESCE($1, archived),\n pinned = COALESCE($2, pinned),\n name = CASE WHEN $3 THEN $4 ELSE name END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $5", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f" -} diff --git a/crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json b/crates/db/.sqlx/query-57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc.json similarity index 77% rename from crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json rename to crates/db/.sqlx/query-57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc.json index da3ce7ad63..6df69dc0be 100644 --- a/crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json +++ b/crates/db/.sqlx/query-57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n ORDER BY created_at DESC", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n archived_at AS \"archived_at: DateTime\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n ORDER BY created_at DESC", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf" + "hash": "57f519d7c860951c3842b2526d9c40417843e0ee975b19864f78fdfae6be03cc" } diff --git a/crates/db/.sqlx/query-6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a.json b/crates/db/.sqlx/query-6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a.json new file mode 100644 index 0000000000..6f6aa1711a --- /dev/null +++ b/crates/db/.sqlx/query-6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.archived_at AS \"archived_at: DateTime\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n ORDER BY w.updated_at DESC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_id: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "container_ref", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "branch", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "setup_completed_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "archived!: bool", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "archived_at: DateTime", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "worktree_deleted!: bool", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "is_running!: i64", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "is_executor_running!: i64", + "ordinal": 13, + "type_info": "Integer" + }, + { + "name": "is_coding_agent_running!: i64", + "ordinal": 14, + "type_info": "Integer" + }, + { + "name": "is_errored!: i64", + "ordinal": 15, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + true, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "6a86ee7ff48b8609459acb190c6a522fab0156fac78a2216851e6e70eb68352a" +} diff --git a/crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json b/crates/db/.sqlx/query-85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27.json similarity index 79% rename from crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json rename to crates/db/.sqlx/query-85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27.json index d77d242dd4..5564388490 100644 --- a/crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json +++ b/crates/db/.sqlx/query-85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE id = $1", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n archived_at AS \"archived_at: DateTime\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE id = $1", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1" + "hash": "85475083809654f6a916ce5157349baf195ff5e112b2e1d83063b51043060a27" } diff --git a/crates/db/.sqlx/query-a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586.json b/crates/db/.sqlx/query-a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586.json deleted file mode 100644 index 71b0c21487..0000000000 --- a/crates/db/.sqlx/query-a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n ORDER BY w.updated_at DESC", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "task_id: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "container_ref", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "branch", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "setup_completed_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "archived!: bool", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "pinned!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "worktree_deleted!: bool", - "ordinal": 10, - "type_info": "Bool" - }, - { - "name": "is_running!: i64", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "is_executor_running!: i64", - "ordinal": 12, - "type_info": "Integer" - }, - { - "name": "is_coding_agent_running!: i64", - "ordinal": 13, - "type_info": "Integer" - }, - { - "name": "is_errored!: i64", - "ordinal": 14, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false - ] - }, - "hash": "a06582428c5e925cfc9e56656e0ff9759a0265755a93ce1f602a6bcf71d4f586" -} diff --git a/crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json b/crates/db/.sqlx/query-a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f.json similarity index 79% rename from crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json rename to crates/db/.sqlx/query-a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f.json index 0d177a4322..684210d720 100644 --- a/crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json +++ b/crates/db/.sqlx/query-a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE rowid = $1", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n archived_at AS \"archived_at: DateTime\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE rowid = $1", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d" + "hash": "a5d6e4601f9a48529036b544140bf1206ecafdc960b3147613ff8c68e988921f" } diff --git a/crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json b/crates/db/.sqlx/query-c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b.json similarity index 82% rename from crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json rename to crates/db/.sqlx/query-c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b.json index de5161171a..529194aa4d 100644 --- a/crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json +++ b/crates/db/.sqlx/query-c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id: Uuid\", container_ref, branch, setup_completed_at as \"setup_completed_at: DateTime\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\", archived as \"archived!: bool\", pinned as \"pinned!: bool\", name, worktree_deleted as \"worktree_deleted!: bool\"", + "query": "INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id: Uuid\", container_ref, branch, setup_completed_at as \"setup_completed_at: DateTime\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\", archived as \"archived!: bool\", archived_at as \"archived_at: DateTime\", pinned as \"pinned!: bool\", name, worktree_deleted as \"worktree_deleted!: bool\"", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -71,10 +76,11 @@ false, false, false, + true, false, true, false ] }, - "hash": "7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c" + "hash": "c0f612c3b7584e9c30b83ee9ff662cadc07ad23b839f983cf82ed6df5fc20e3b" } diff --git a/crates/db/.sqlx/query-d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb.json b/crates/db/.sqlx/query-d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb.json new file mode 100644 index 0000000000..f63a1c4887 --- /dev/null +++ b/crates/db/.sqlx/query-d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE workspaces SET\n archived = COALESCE($1, archived),\n archived_at = CASE\n WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec')\n WHEN $1 = FALSE THEN NULL\n ELSE archived_at\n END,\n pinned = COALESCE($2, pinned),\n name = CASE WHEN $3 THEN $4 ELSE name END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $5", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "d0eafe01b623b10aae3fb09a3c5f993acba8bef194cda58722854394e84d39bb" +} diff --git a/crates/db/.sqlx/query-e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12.json b/crates/db/.sqlx/query-e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12.json deleted file mode 100644 index 579a9a7644..0000000000 --- a/crates/db/.sqlx/query-e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) OR EXISTS (\n -- CLI-mode tmux claude actively producing output\n SELECT 1\n FROM workspace_cli_activity ca\n WHERE ca.workspace_id = w.id\n AND ca.state = 'running'\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_executor_running!: i64\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_coding_agent_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n WHERE w.id = $1", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "task_id: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "container_ref", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "branch", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "setup_completed_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "archived!: bool", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "pinned!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "worktree_deleted!: bool", - "ordinal": 10, - "type_info": "Bool" - }, - { - "name": "is_running!: i64", - "ordinal": 11, - "type_info": "Null" - }, - { - "name": "is_executor_running!: i64", - "ordinal": 12, - "type_info": "Null" - }, - { - "name": "is_coding_agent_running!: i64", - "ordinal": 13, - "type_info": "Null" - }, - { - "name": "is_errored!: i64", - "ordinal": 14, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - false, - null, - null, - null, - null - ] - }, - "hash": "e34ea49c71c3bf57f6e5ec312b6dde3df496e13a80067ecb9a58996018ab9f12" -} diff --git a/crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json b/crates/db/.sqlx/query-f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854.json similarity index 52% rename from crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json rename to crates/db/.sqlx/query-f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854.json index 1008829ef9..f927cc9b4e 100644 --- a/crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json +++ b/crates/db/.sqlx/query-f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n w.id as \"id!: Uuid\",\n w.task_id as \"task_id: Uuid\",\n w.container_ref,\n w.branch as \"branch!\",\n w.setup_completed_at as \"setup_completed_at: DateTime\",\n w.created_at as \"created_at!: DateTime\",\n w.updated_at as \"updated_at!: DateTime\",\n w.archived as \"archived!: bool\",\n w.pinned as \"pinned!: bool\",\n w.name,\n w.worktree_deleted as \"worktree_deleted!: bool\"\n FROM workspaces w\n LEFT JOIN sessions s ON w.id = s.workspace_id\n LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL\n WHERE w.container_ref IS NOT NULL\n AND w.worktree_deleted = FALSE\n AND w.id NOT IN (\n SELECT DISTINCT s2.workspace_id\n FROM sessions s2\n JOIN execution_processes ep2 ON s2.id = ep2.session_id\n WHERE ep2.completed_at IS NULL\n )\n GROUP BY w.id, w.container_ref, w.updated_at\n HAVING datetime('now', 'localtime',\n CASE\n WHEN w.archived = 1\n THEN '-1 hours'\n ELSE '-72 hours'\n END\n ) > datetime(\n MAX(\n max(\n datetime(w.updated_at),\n datetime(ep.completed_at)\n )\n )\n )\n ORDER BY MAX(\n CASE\n WHEN ep.completed_at IS NOT NULL THEN ep.completed_at\n ELSE w.updated_at\n END\n ) ASC\n ", + "query": "\n SELECT\n w.id as \"id!: Uuid\",\n w.task_id as \"task_id: Uuid\",\n w.container_ref,\n w.branch as \"branch!\",\n w.setup_completed_at as \"setup_completed_at: DateTime\",\n w.created_at as \"created_at!: DateTime\",\n w.updated_at as \"updated_at!: DateTime\",\n w.archived as \"archived!: bool\",\n w.archived_at as \"archived_at: DateTime\",\n w.pinned as \"pinned!: bool\",\n w.name,\n w.worktree_deleted as \"worktree_deleted!: bool\"\n FROM workspaces w\n LEFT JOIN sessions s ON w.id = s.workspace_id\n LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL\n WHERE w.container_ref IS NOT NULL\n AND w.worktree_deleted = FALSE\n AND w.id NOT IN (\n SELECT DISTINCT s2.workspace_id\n FROM sessions s2\n JOIN execution_processes ep2 ON s2.id = ep2.session_id\n WHERE ep2.completed_at IS NULL\n )\n GROUP BY w.id, w.container_ref, w.updated_at\n HAVING datetime('now', 'localtime',\n CASE\n WHEN w.archived = 1\n THEN '-1 hours'\n ELSE '-72 hours'\n END\n ) > datetime(\n MAX(\n max(\n datetime(w.updated_at),\n datetime(ep.completed_at)\n )\n )\n )\n ORDER BY MAX(\n CASE\n WHEN ep.completed_at IS NOT NULL THEN ep.completed_at\n ELSE w.updated_at\n END\n ) ASC\n ", "describe": { "columns": [ { @@ -44,18 +44,23 @@ "type_info": "Integer" }, { - "name": "pinned!: bool", + "name": "archived_at: DateTime", "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "pinned!: bool", + "ordinal": 9, "type_info": "Integer" }, { "name": "name", - "ordinal": 9, + "ordinal": 10, "type_info": "Text" }, { "name": "worktree_deleted!: bool", - "ordinal": 10, + "ordinal": 11, "type_info": "Bool" } ], @@ -73,8 +78,9 @@ true, true, true, + true, true ] }, - "hash": "91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e" + "hash": "f56e9e521125dfbd84815ebefebfc30f2d0fa7b607c447cb0a8789a9c5442854" } diff --git a/crates/db/src/models/workspace.rs b/crates/db/src/models/workspace.rs index f8d138b3ae..681fe29b3a 100644 --- a/crates/db/src/models/workspace.rs +++ b/crates/db/src/models/workspace.rs @@ -48,6 +48,7 @@ pub struct Workspace { pub created_at: DateTime, pub updated_at: DateTime, pub archived: bool, + pub archived_at: Option>, pub pinned: bool, pub name: Option, pub worktree_deleted: bool, @@ -112,6 +113,7 @@ impl Workspace { created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", + archived_at AS "archived_at: DateTime", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" @@ -214,6 +216,7 @@ impl Workspace { created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", + archived_at AS "archived_at: DateTime", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" @@ -236,6 +239,7 @@ impl Workspace { created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", + archived_at AS "archived_at: DateTime", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" @@ -279,6 +283,7 @@ impl Workspace { w.created_at as "created_at!: DateTime", w.updated_at as "updated_at!: DateTime", w.archived as "archived!: bool", + w.archived_at as "archived_at: DateTime", w.pinned as "pinned!: bool", w.name, w.worktree_deleted as "worktree_deleted!: bool" @@ -329,7 +334,7 @@ impl Workspace { Workspace, r#"INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name) VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id as "id!: Uuid", task_id as "task_id: Uuid", container_ref, branch, setup_completed_at as "setup_completed_at: DateTime", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime", archived as "archived!: bool", pinned as "pinned!: bool", name, worktree_deleted as "worktree_deleted!: bool""#, + RETURNING id as "id!: Uuid", task_id as "task_id: Uuid", container_ref, branch, setup_completed_at as "setup_completed_at: DateTime", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime", archived as "archived!: bool", archived_at as "archived_at: DateTime", pinned as "pinned!: bool", name, worktree_deleted as "worktree_deleted!: bool""#, id, Option::::None, Option::::None, @@ -454,7 +459,15 @@ impl Workspace { archived: bool, ) -> Result<(), sqlx::Error> { sqlx::query!( - "UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2", + r#"UPDATE workspaces SET + archived = $1, + archived_at = CASE + WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec') + WHEN $1 = FALSE THEN NULL + ELSE archived_at + END, + updated_at = datetime('now', 'subsec') + WHERE id = $2"#, archived, workspace_id ) @@ -479,6 +492,11 @@ impl Workspace { sqlx::query!( r#"UPDATE workspaces SET archived = COALESCE($1, archived), + archived_at = CASE + WHEN $1 = TRUE AND archived = FALSE THEN datetime('now', 'subsec') + WHEN $1 = FALSE THEN NULL + ELSE archived_at + END, pinned = COALESCE($2, pinned), name = CASE WHEN $3 THEN $4 ELSE name END, updated_at = datetime('now', 'subsec') @@ -571,6 +589,7 @@ impl Workspace { w.created_at AS "created_at!: DateTime", w.updated_at AS "updated_at!: DateTime", w.archived AS "archived!: bool", + w.archived_at AS "archived_at: DateTime", w.pinned AS "pinned!: bool", w.name, w.worktree_deleted AS "worktree_deleted!: bool", @@ -639,6 +658,7 @@ impl Workspace { created_at: rec.created_at, updated_at: rec.updated_at, archived: rec.archived, + archived_at: rec.archived_at, pinned: rec.pinned, name: rec.name, worktree_deleted: rec.worktree_deleted, @@ -693,6 +713,7 @@ impl Workspace { w.created_at AS "created_at!: DateTime", w.updated_at AS "updated_at!: DateTime", w.archived AS "archived!: bool", + w.archived_at AS "archived_at: DateTime", w.pinned AS "pinned!: bool", w.name, w.worktree_deleted AS "worktree_deleted!: bool", @@ -764,6 +785,7 @@ impl Workspace { created_at: rec.created_at, updated_at: rec.updated_at, archived: rec.archived, + archived_at: rec.archived_at, pinned: rec.pinned, name: rec.name, worktree_deleted: rec.worktree_deleted, @@ -788,9 +810,35 @@ impl Workspace { #[cfg(test)] mod tests { + use std::time::Duration; + + use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; use uuid::Uuid; - use super::Workspace; + use super::{CreateWorkspace, Workspace}; + + async fn test_pool() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::run_migrations_for_tests(&pool).await.unwrap(); + pool + } + + async fn create_test_workspace(pool: &SqlitePool) -> Workspace { + Workspace::create( + pool, + &CreateWorkspace { + branch: "archive-timestamp-test".to_string(), + name: Some("Archive timestamp test".to_string()), + }, + Uuid::new_v4(), + ) + .await + .unwrap() + } #[test] fn best_matching_container_ref_prefers_deepest_match() { @@ -825,4 +873,118 @@ mod tests { assert_eq!(selected, None); } + + #[tokio::test] + async fn update_tracks_archive_timestamp_transitions() { + let pool = test_pool().await; + let workspace = create_test_workspace(&pool).await; + + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + let first_archived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("archiving should set archived_at"); + + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + Some(first_archived_at), + "redundant archive writes must preserve the original transition time" + ); + + Workspace::update(&pool, workspace.id, Some(false), None, None) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + None, + "unarchiving through update must clear archived_at" + ); + + tokio::time::sleep(Duration::from_millis(10)).await; + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + let rearchived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("re-archiving should set archived_at again"); + assert!(rearchived_at > first_archived_at); + } + + #[tokio::test] + async fn set_archived_tracks_archive_timestamp_transitions() { + let pool = test_pool().await; + let workspace = create_test_workspace(&pool).await; + + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + let first_archived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("archiving should set archived_at"); + + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + Workspace::update(&pool, workspace.id, Some(true), None, None) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + Some(first_archived_at), + "redundant archive writes must preserve the original transition time" + ); + + Workspace::set_archived(&pool, workspace.id, false) + .await + .unwrap(); + assert_eq!( + Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at, + None, + "unarchiving through set_archived must clear archived_at" + ); + + tokio::time::sleep(Duration::from_millis(10)).await; + Workspace::set_archived(&pool, workspace.id, true) + .await + .unwrap(); + let rearchived_at = Workspace::find_by_id(&pool, workspace.id) + .await + .unwrap() + .unwrap() + .archived_at + .expect("re-archiving should set archived_at again"); + assert!(rearchived_at > first_archived_at); + } } diff --git a/shared/types.ts b/shared/types.ts index 18708b7222..e6bfee89fb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -164,7 +164,7 @@ export type CreateScratch = { payload: ScratchPayload, }; export type UpdateScratch = { payload: ScratchPayload, }; -export type Workspace = { id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, }; +export type Workspace = { id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, archived_at: string | null, pinned: boolean, name: string | null, worktree_deleted: boolean, }; export type WorkspaceWithStatus = { /** @@ -183,7 +183,7 @@ is_executor_running: boolean, * scripts). Lets the CLI gate phrase itself correctly: "claude is * working in chat" vs "preparing the workspace". */ -is_coding_agent_running: boolean, is_errored: boolean, id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, }; +is_coding_agent_running: boolean, is_errored: boolean, id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, archived_at: string | null, pinned: boolean, name: string | null, worktree_deleted: boolean, }; export type Session = { id: string, workspace_id: string, name: string | null, executor: string | null, agent_working_dir: string | null, created_at: string, updated_at: string, }; From 832a0a9766e41f53162c46da1cca15b4aebc2077 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 19:44:20 +0000 Subject: [PATCH 03/25] db: define archived workspace age buckets --- crates/db/src/models/archive_bucket.rs | 104 ++++++++++++++++++++++++ crates/db/src/models/mod.rs | 1 + crates/server/src/bin/generate_types.rs | 1 + shared/types.ts | 2 + 4 files changed, 108 insertions(+) create mode 100644 crates/db/src/models/archive_bucket.rs diff --git a/crates/db/src/models/archive_bucket.rs b/crates/db/src/models/archive_bucket.rs new file mode 100644 index 0000000000..88794cd862 --- /dev/null +++ b/crates/db/src/models/archive_bucket.rs @@ -0,0 +1,104 @@ +use chrono::Duration; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Non-overlapping age ranges for archived workspaces. +/// +/// Keep this half-open boundary table in sync with +/// `packages/web-core/src/shared/lib/archiveBuckets.ts`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ArchiveBucket { + Today, + OneToThreeDays, + ThreeToSevenDays, + SevenToFifteenDays, + FifteenToThirtyDays, + OlderThanThirtyDays, +} + +impl ArchiveBucket { + pub fn from_age(age: Duration) -> Self { + // `num_days` floors positive durations to complete days. Clamp clock + // skew/future timestamps to today instead of producing a negative age. + match age.num_days().max(0) { + 0 => Self::Today, + 1..=2 => Self::OneToThreeDays, + 3..=6 => Self::ThreeToSevenDays, + 7..=14 => Self::SevenToFifteenDays, + 15..=29 => Self::FifteenToThirtyDays, + _ => Self::OlderThanThirtyDays, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + + use super::ArchiveBucket; + + #[test] + fn from_age_pins_every_bucket_boundary() { + let millisecond = Duration::milliseconds(1); + let cases = [ + (Duration::zero(), ArchiveBucket::Today), + (millisecond, ArchiveBucket::Today), + (Duration::days(1) - millisecond, ArchiveBucket::Today), + (Duration::days(1), ArchiveBucket::OneToThreeDays), + ( + Duration::days(1) + millisecond, + ArchiveBucket::OneToThreeDays, + ), + ( + Duration::days(3) - millisecond, + ArchiveBucket::OneToThreeDays, + ), + (Duration::days(3), ArchiveBucket::ThreeToSevenDays), + ( + Duration::days(3) + millisecond, + ArchiveBucket::ThreeToSevenDays, + ), + ( + Duration::days(7) - millisecond, + ArchiveBucket::ThreeToSevenDays, + ), + (Duration::days(7), ArchiveBucket::SevenToFifteenDays), + ( + Duration::days(7) + millisecond, + ArchiveBucket::SevenToFifteenDays, + ), + ( + Duration::days(15) - millisecond, + ArchiveBucket::SevenToFifteenDays, + ), + (Duration::days(15), ArchiveBucket::FifteenToThirtyDays), + ( + Duration::days(15) + millisecond, + ArchiveBucket::FifteenToThirtyDays, + ), + ( + Duration::days(30) - millisecond, + ArchiveBucket::FifteenToThirtyDays, + ), + (Duration::days(30), ArchiveBucket::OlderThanThirtyDays), + ( + Duration::days(30) + millisecond, + ArchiveBucket::OlderThanThirtyDays, + ), + ]; + + for (age, expected) in cases { + assert_eq!(ArchiveBucket::from_age(age), expected, "age: {age:?}"); + } + } + + #[test] + fn from_age_clamps_future_timestamps_to_today() { + assert_eq!( + ArchiveBucket::from_age(Duration::days(-10)), + ArchiveBucket::Today + ); + } +} diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 1b95adc165..0b45815771 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod archive_bucket; pub mod claude_session_link; pub mod cli_ingest_outbox; pub mod cli_native_file; diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 3cef48528f..a4cab8bb91 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -45,6 +45,7 @@ fn generate_types_content() -> String { db::models::scratch::UpdateScratch::decl(), db::models::workspace::Workspace::decl(), db::models::workspace::WorkspaceWithStatus::decl(), + db::models::archive_bucket::ArchiveBucket::decl(), db::models::session::Session::decl(), db::models::session_queued_message::QueuedMessageSource::decl(), db::models::session_queued_message::QueuedMessageState::decl(), diff --git a/shared/types.ts b/shared/types.ts index e6bfee89fb..0986ebf327 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -185,6 +185,8 @@ is_executor_running: boolean, */ is_coding_agent_running: boolean, is_errored: boolean, id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, archived_at: string | null, pinned: boolean, name: string | null, worktree_deleted: boolean, }; +export type ArchiveBucket = "today" | "one_to_three_days" | "three_to_seven_days" | "seven_to_fifteen_days" | "fifteen_to_thirty_days" | "older_than_thirty_days"; + export type Session = { id: string, workspace_id: string, name: string | null, executor: string | null, agent_working_dir: string | null, created_at: string, updated_at: string, }; export type QueuedMessageSource = "ui" | "recovery"; From 551b7dddca90b83ebb4949fd54251b73348ec4de Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 19:46:41 +0000 Subject: [PATCH 04/25] refactor(server): share workspace deletion core --- crates/server/src/routes/workspaces/core.rs | 43 +++++++++++++++++---- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/server/src/routes/workspaces/core.rs b/crates/server/src/routes/workspaces/core.rs index 42977e9c7c..c6c0db8a88 100644 --- a/crates/server/src/routes/workspaces/core.rs +++ b/crates/server/src/routes/workspaces/core.rs @@ -26,6 +26,15 @@ pub struct DeleteWorkspaceQuery { pub delete_branches: bool, } +const RUNNING_PROCESSES_DELETE_MESSAGE: &str = + "Cannot delete workspace while processes are running. Stop all processes first."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteWorkspaceOutcome { + Deleted, + SkippedRunningProcesses, +} + pub async fn get_workspaces( State(deployment): State, ) -> Result>>, ApiError> { @@ -101,6 +110,29 @@ pub async fn delete_workspace( State(deployment): State, Query(query): Query, ) -> Result<(StatusCode, ResponseJson>), ApiError> { + match delete_workspace_core( + &deployment, + workspace, + query.delete_remote, + query.delete_branches, + ) + .await? + { + DeleteWorkspaceOutcome::Deleted => { + Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(())))) + } + DeleteWorkspaceOutcome::SkippedRunningProcesses => Err(ApiError::Conflict( + RUNNING_PROCESSES_DELETE_MESSAGE.to_string(), + )), + } +} + +pub async fn delete_workspace_core( + deployment: &DeploymentImpl, + workspace: Workspace, + delete_remote: bool, + delete_branches: bool, +) -> Result { let pool = &deployment.db().pool; let workspace_manager = deployment.workspace_manager(); let workspace_id = workspace.id; @@ -108,10 +140,7 @@ pub async fn delete_workspace( if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace_id) .await? { - return Err(ApiError::Conflict( - "Cannot delete workspace while processes are running. Stop all processes first." - .to_string(), - )); + return Ok(DeleteWorkspaceOutcome::SkippedRunningProcesses); } let dev_servers = @@ -163,7 +192,7 @@ pub async fn delete_workspace( ) .await; - if query.delete_remote { + if delete_remote { if let Ok(client) = deployment.remote_client() { match client.delete_workspace(workspace_id).await { Ok(()) => { @@ -185,9 +214,9 @@ pub async fn delete_workspace( } } - WorkspaceManager::spawn_workspace_deletion_cleanup(deletion_context, query.delete_branches); + WorkspaceManager::spawn_workspace_deletion_cleanup(deletion_context, delete_branches); - Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(())))) + Ok(DeleteWorkspaceOutcome::Deleted) } #[axum::debug_handler] From 9c052d33ef84db0ee967539fdb0f9a8b773abe22 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 19:55:26 +0000 Subject: [PATCH 05/25] feat(server): bulk delete archived workspace buckets --- Cargo.lock | 1 + crates/server/Cargo.toml | 3 + crates/server/src/bin/generate_types.rs | 4 + crates/server/src/routes/workspaces/core.rs | 120 +++++++++++++- crates/server/src/routes/workspaces/mod.rs | 4 + .../tests/bulk_delete_archived_workspaces.rs | 156 ++++++++++++++++++ shared/types.ts | 8 + 7 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 crates/server/tests/bulk_delete_archived_workspaces.rs diff --git a/Cargo.lock b/Cargo.lock index 2ac55177f6..53e5cb595a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8925,6 +8925,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.26.2", "tokio-util", + "tower", "tower-http 0.5.2", "tracing", "tracing-subscriber", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index a5a2185a87..95f7f3ff76 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -74,6 +74,9 @@ tempfile = "3" [build-dependencies] dotenv = "0.15" +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } + [features] default = [] qa-mode = ["services/qa-mode", "executors/qa-mode"] diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index a4cab8bb91..6a1dafd619 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -145,6 +145,10 @@ fn generate_types_content() -> String { server::routes::sessions::ResetProcessRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchResponse::decl(), + server::routes::workspaces::core::BulkDeleteArchivedWorkspacesRequest::decl(), + server::routes::workspaces::core::BulkDeleteItemOutcome::decl(), + server::routes::workspaces::core::BulkDeleteItemResult::decl(), + server::routes::workspaces::core::BulkDeleteArchivedWorkspacesResponse::decl(), server::routes::workspaces::repos::AddWorkspaceRepoRequest::decl(), server::routes::workspaces::repos::AddWorkspaceRepoResponse::decl(), server::routes::workspaces::git::MergeWorkspaceRequest::decl(), diff --git a/crates/server/src/routes/workspaces/core.rs b/crates/server/src/routes/workspaces/core.rs index c6c0db8a88..200fc6f97e 100644 --- a/crates/server/src/routes/workspaces/core.rs +++ b/crates/server/src/routes/workspaces/core.rs @@ -4,16 +4,20 @@ use axum::{ http::StatusCode, response::Json as ResponseJson, }; +use chrono::Utc; use db::models::{ + archive_bucket::ArchiveBucket, coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessStatus}, workspace::{Workspace, WorkspaceError}, }; use deployment::Deployment; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use services::services::{container::ContainerService, diff_stream, remote_sync}; use sqlx::Error as SqlxError; +use ts_rs::TS; use utils::response::ApiResponse; +use uuid::Uuid; use workspace_manager::WorkspaceManager; use crate::{DeploymentImpl, error::ApiError}; @@ -35,6 +39,33 @@ pub enum DeleteWorkspaceOutcome { SkippedRunningProcesses, } +#[derive(Debug, Deserialize, TS)] +pub struct BulkDeleteArchivedWorkspacesRequest { + pub bucket: ArchiveBucket, + pub delete_branches: bool, +} + +#[derive(Debug, Serialize, TS)] +#[serde(tag = "status", rename_all = "snake_case")] +#[ts(tag = "status", rename_all = "snake_case")] +pub enum BulkDeleteItemOutcome { + Deleted, + Skipped { reason: String }, + Failed { reason: String }, +} + +#[derive(Debug, Serialize, TS)] +pub struct BulkDeleteItemResult { + pub workspace_id: Uuid, + pub workspace_name: Option, + pub outcome: BulkDeleteItemOutcome, +} + +#[derive(Debug, Serialize, TS)] +pub struct BulkDeleteArchivedWorkspacesResponse { + pub results: Vec, +} + pub async fn get_workspaces( State(deployment): State, ) -> Result>>, ApiError> { @@ -105,6 +136,93 @@ pub async fn get_first_user_message( Ok(ResponseJson(ApiResponse::success(message))) } +pub async fn bulk_delete_archived_workspaces( + State(deployment): State, + Json(request): Json, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + let now = Utc::now(); + let targets = Workspace::fetch_all(pool) + .await? + .into_iter() + .filter(|workspace| workspace.archived) + .filter(|workspace| { + let bucket = workspace.archived_at.map_or( + // An archived row without a timestamp has unknown age. Keep it + // in the oldest bucket so it never appears deceptively fresh. + ArchiveBucket::OlderThanThirtyDays, + |archived_at| ArchiveBucket::from_age(now.signed_duration_since(archived_at)), + ); + bucket == request.bucket + }) + .map(|workspace| (workspace.id, workspace.name)) + .collect::>(); + + let mut results = Vec::with_capacity(targets.len()); + for (workspace_id, resolved_name) in targets { + let fresh_workspace = match Workspace::find_by_id(pool, workspace_id).await { + Ok(Some(workspace)) if !workspace.archived => { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name: workspace.name, + outcome: BulkDeleteItemOutcome::Skipped { + reason: "no longer archived".to_string(), + }, + }); + continue; + } + Ok(Some(workspace)) => workspace, + Ok(None) => { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name: resolved_name, + outcome: BulkDeleteItemOutcome::Skipped { + reason: "already deleted".to_string(), + }, + }); + continue; + } + Err(error) => { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name: resolved_name, + outcome: BulkDeleteItemOutcome::Failed { + reason: error.to_string(), + }, + }); + continue; + } + }; + let workspace_name = fresh_workspace.name.clone(); + + let outcome = match delete_workspace_core( + &deployment, + fresh_workspace, + false, + request.delete_branches, + ) + .await + { + Ok(DeleteWorkspaceOutcome::Deleted) => BulkDeleteItemOutcome::Deleted, + Ok(DeleteWorkspaceOutcome::SkippedRunningProcesses) => BulkDeleteItemOutcome::Skipped { + reason: RUNNING_PROCESSES_DELETE_MESSAGE.to_string(), + }, + Err(error) => BulkDeleteItemOutcome::Failed { + reason: error.to_string(), + }, + }; + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name, + outcome, + }); + } + + Ok(ResponseJson(ApiResponse::success( + BulkDeleteArchivedWorkspacesResponse { results }, + ))) +} + pub async fn delete_workspace( Extension(workspace): Extension, State(deployment): State, diff --git a/crates/server/src/routes/workspaces/mod.rs b/crates/server/src/routes/workspaces/mod.rs index 3faecbf00e..1af0d54d0a 100644 --- a/crates/server/src/routes/workspaces/mod.rs +++ b/crates/server/src/routes/workspaces/mod.rs @@ -50,6 +50,10 @@ pub fn router(deployment: &DeploymentImpl) -> Router { ) .route("/start", post(create::create_and_start_workspace)) .route("/from-pr", post(pr::create_workspace_from_pr)) + .route( + "/archived/bulk-delete", + post(core::bulk_delete_archived_workspaces), + ) .route("/streams/ws", get(streams::stream_workspaces_ws)) .route( "/summaries", diff --git a/crates/server/tests/bulk_delete_archived_workspaces.rs b/crates/server/tests/bulk_delete_archived_workspaces.rs new file mode 100644 index 0000000000..5012401f51 --- /dev/null +++ b/crates/server/tests/bulk_delete_archived_workspaces.rs @@ -0,0 +1,156 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use chrono::{Duration, Utc}; +use db::models::{ + session::{CreateSession, Session}, + workspace::{CreateWorkspace, Workspace}, +}; +use deployment::Deployment; +use local_deployment::LocalDeployment; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; +use uuid::Uuid; + +async fn create_archived_workspace( + deployment: &LocalDeployment, + name: &str, + age: Duration, +) -> Workspace { + let workspace = Workspace::create( + &deployment.db().pool, + &CreateWorkspace { + branch: format!("test-{name}"), + name: Some(name.to_string()), + }, + Uuid::new_v4(), + ) + .await + .unwrap(); + Workspace::set_archived(&deployment.db().pool, workspace.id, true) + .await + .unwrap(); + sqlx::query("UPDATE workspaces SET archived_at = ? WHERE id = ?") + .bind(Utc::now() - age) + .bind(workspace.id) + .execute(&deployment.db().pool) + .await + .unwrap(); + Workspace::find_by_id(&deployment.db().pool, workspace.id) + .await + .unwrap() + .unwrap() +} + +async fn add_running_process(deployment: &LocalDeployment, workspace_id: Uuid) { + let session = Session::create( + &deployment.db().pool, + &CreateSession { + executor: None, + name: Some("running process".to_string()), + }, + Uuid::new_v4(), + workspace_id, + ) + .await + .unwrap(); + sqlx::query( + "INSERT INTO execution_processes (id, session_id, run_reason, executor_action, status) VALUES (?, ?, 'codingagent', '{}', 'running')", + ) + .bind(Uuid::new_v4()) + .bind(session.id) + .execute(&deployment.db().pool) + .await + .unwrap(); +} + +fn result_for<'a>(results: &'a [Value], workspace_id: Uuid) -> &'a Value { + results + .iter() + .find(|result| result["workspace_id"] == workspace_id.to_string()) + .unwrap_or_else(|| panic!("missing result for workspace {workspace_id}")) +} + +#[tokio::test] +async fn bulk_delete_resolves_bucket_and_reports_each_item_outcome() { + let data_dir = TempDir::new().unwrap(); + // This integration test is its own process and owns the environment before + // LocalDeployment initializes its process-wide data-directory cache. + unsafe { + std::env::set_var("BC_DATA_DIR", data_dir.path()); + std::env::remove_var("BC_SHARED_API_BASE"); + std::env::remove_var("VK_SHARED_API_BASE"); + std::env::remove_var("BC_SHARED_RELAY_API_BASE"); + std::env::remove_var("VK_SHARED_RELAY_API_BASE"); + } + + let shutdown = CancellationToken::new(); + let deployment = LocalDeployment::new(shutdown.child_token()).await.unwrap(); + let deletable = create_archived_workspace(&deployment, "deletable", Duration::days(4)).await; + let running = create_archived_workspace(&deployment, "running", Duration::days(5)).await; + let today = create_archived_workspace(&deployment, "today", Duration::hours(2)).await; + let older = create_archived_workspace(&deployment, "older", Duration::days(20)).await; + add_running_process(&deployment, running.id).await; + + let app = Router::new() + .merge(server::routes::workspaces::router(&deployment)) + .with_state(deployment.clone()); + let response = app + .oneshot( + Request::post("/workspaces/archived/bulk-delete") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "bucket": "three_to_seven_days", + "delete_branches": true, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["success"], true); + let results = payload["data"]["results"].as_array().unwrap(); + assert_eq!(results.len(), 2); + assert_eq!( + result_for(results, deletable.id)["outcome"]["status"], + "deleted" + ); + assert_eq!( + result_for(results, running.id)["outcome"]["status"], + "skipped" + ); + assert!( + result_for(results, running.id)["outcome"]["reason"] + .as_str() + .unwrap() + .contains("processes are running") + ); + + assert!( + Workspace::find_by_id(&deployment.db().pool, deletable.id) + .await + .unwrap() + .is_none() + ); + for untouched_id in [running.id, today.id, older.id] { + assert!( + Workspace::find_by_id(&deployment.db().pool, untouched_id) + .await + .unwrap() + .is_some(), + "workspace {untouched_id} must remain" + ); + } + + shutdown.cancel(); +} diff --git a/shared/types.ts b/shared/types.ts index 0986ebf327..291aacb04e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -420,6 +420,14 @@ export type ChangeTargetBranchRequest = { repo_id: string, new_target_branch: st export type ChangeTargetBranchResponse = { repo_id: string, new_target_branch: string, status: [number, number], }; +export type BulkDeleteArchivedWorkspacesRequest = { bucket: ArchiveBucket, delete_branches: boolean, }; + +export type BulkDeleteItemOutcome = { "status": "deleted" } | { "status": "skipped", reason: string, } | { "status": "failed", reason: string, }; + +export type BulkDeleteItemResult = { workspace_id: string, workspace_name: string | null, outcome: BulkDeleteItemOutcome, }; + +export type BulkDeleteArchivedWorkspacesResponse = { results: Array, }; + export type AddWorkspaceRepoRequest = { repo_id: string, target_branch: string, }; export type AddWorkspaceRepoResponse = { workspace: Workspace, repo: RepoWithTargetBranch, }; From 0a853cbe6c6842c3c7c7603817d14e02b9183522 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 19:59:58 +0000 Subject: [PATCH 06/25] feat(server): expose workspace repository counts --- .../routes/workspaces/workspace_summary.rs | 19 +++++++++++++++++-- shared/types.ts | 4 ++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/server/src/routes/workspaces/workspace_summary.rs b/crates/server/src/routes/workspaces/workspace_summary.rs index ff7f7b717e..c3296142b4 100644 --- a/crates/server/src/routes/workspaces/workspace_summary.rs +++ b/crates/server/src/routes/workspaces/workspace_summary.rs @@ -8,6 +8,7 @@ use db::models::{ pull_request::PullRequest, workspace::Workspace, workspace_cli_activity::WorkspaceCliActivity, + workspace_repo::WorkspaceRepo, }; use deployment::Deployment; use serde::{Deserialize, Serialize}; @@ -27,6 +28,8 @@ pub struct WorkspaceSummaryRequest { #[derive(Debug, Serialize, TS)] pub struct WorkspaceSummary { pub workspace_id: Uuid, + /// Number of repositories/worktrees owned by this workspace. + pub repo_count: usize, /// Session ID of the latest execution process pub latest_session_id: Option, /// Is a tool approval currently pending? @@ -120,7 +123,18 @@ pub async fn get_workspace_summaries( // 6. Get PR status for each workspace let pr_statuses = PullRequest::get_latest_for_workspaces(pool, archived).await?; - // 7. Compute diff stats for each workspace (in parallel) + // 7. Count repositories/worktrees for each workspace (in parallel) + let repo_count_futures = workspaces.iter().map(|workspace| async move { + WorkspaceRepo::find_by_workspace_id(pool, workspace.id) + .await + .map(|repos| (workspace.id, repos.len())) + }); + let repo_counts: HashMap = futures_util::future::join_all(repo_count_futures) + .await + .into_iter() + .collect::>()?; + + // 8. Compute diff stats for each workspace (in parallel) let diff_futures: Vec<_> = workspaces .iter() .map(|ws| { @@ -142,7 +156,7 @@ pub async fn get_workspace_summaries( futures_util::future::join_all(diff_futures).await; let diff_stats: HashMap = diff_results.into_iter().flatten().collect(); - // 8. Assemble response + // 9. Assemble response let summaries: Vec = workspaces .iter() .map(|ws| { @@ -155,6 +169,7 @@ pub async fn get_workspace_summaries( WorkspaceSummary { workspace_id: id, + repo_count: repo_counts.get(&id).copied().unwrap_or_default(), latest_session_id: latest.map(|p| p.session_id), has_pending_approval: has_pending, files_changed: stats.map(|s| s.files_changed), diff --git a/shared/types.ts b/shared/types.ts index 291aacb04e..23ac334d66 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -566,6 +566,10 @@ export type UpdateSession = { name: string | null, }; export type WorkspaceSummaryRequest = { archived: boolean, }; export type WorkspaceSummary = { workspace_id: string, +/** + * Number of repositories/worktrees owned by this workspace. + */ +repo_count: number, /** * Session ID of the latest execution process */ From 33f87bca939e3be7b8b893b5fc02515cc9501cde Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 20:04:16 +0000 Subject: [PATCH 07/25] feat(web): add archive bucket data plumbing --- crates/db/src/models/archive_bucket.rs | 2 +- .../ui/src/components/WorkspacesSidebar.tsx | 2 + packages/ui/src/lib/archiveBuckets.ts | 63 ++++++++++++++++++ .../src/shared/hooks/useWorkspaces.ts | 4 ++ packages/web-core/src/shared/lib/api.ts | 12 ++++ .../src/shared/lib/archiveBuckets.test.ts | 65 +++++++++++++++++++ .../web-core/src/shared/lib/archiveBuckets.ts | 9 +++ 7 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/lib/archiveBuckets.ts create mode 100644 packages/web-core/src/shared/lib/archiveBuckets.test.ts create mode 100644 packages/web-core/src/shared/lib/archiveBuckets.ts diff --git a/crates/db/src/models/archive_bucket.rs b/crates/db/src/models/archive_bucket.rs index 88794cd862..57c0d27b15 100644 --- a/crates/db/src/models/archive_bucket.rs +++ b/crates/db/src/models/archive_bucket.rs @@ -5,7 +5,7 @@ use ts_rs::TS; /// Non-overlapping age ranges for archived workspaces. /// /// Keep this half-open boundary table in sync with -/// `packages/web-core/src/shared/lib/archiveBuckets.ts`. +/// `packages/ui/src/lib/archiveBuckets.ts`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/packages/ui/src/components/WorkspacesSidebar.tsx b/packages/ui/src/components/WorkspacesSidebar.tsx index a939565a42..0119680863 100644 --- a/packages/ui/src/components/WorkspacesSidebar.tsx +++ b/packages/ui/src/components/WorkspacesSidebar.tsx @@ -22,6 +22,8 @@ export type WorkspaceLayoutMode = 'flat' | 'accordion'; export interface WorkspacesSidebarWorkspace { id: string; name: string; + archivedAt: string | null; + repoCount?: number; filesChanged?: number; linesAdded?: number; linesRemoved?: number; diff --git a/packages/ui/src/lib/archiveBuckets.ts b/packages/ui/src/lib/archiveBuckets.ts new file mode 100644 index 0000000000..0979e521da --- /dev/null +++ b/packages/ui/src/lib/archiveBuckets.ts @@ -0,0 +1,63 @@ +import type { ArchiveBucket } from 'shared/types'; + +export const ARCHIVE_BUCKET_ORDER: readonly ArchiveBucket[] = [ + 'today', + 'one_to_three_days', + 'three_to_seven_days', + 'seven_to_fifteen_days', + 'fifteen_to_thirty_days', + 'older_than_thirty_days', +]; + +export const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; + +/** + * Maps a duration to the same half-open archive-age buckets as + * `crates/db/src/models/archive_bucket.rs`. Keep both boundary tables in sync. + */ +export function archiveBucketFromAgeMilliseconds( + ageMilliseconds: number +): ArchiveBucket { + if (!Number.isFinite(ageMilliseconds)) { + return 'older_than_thirty_days'; + } + + const ageDays = Math.floor( + Math.max(0, ageMilliseconds) / DAY_IN_MILLISECONDS + ); + + if (ageDays === 0) return 'today'; + if (ageDays < 3) return 'one_to_three_days'; + if (ageDays < 7) return 'three_to_seven_days'; + if (ageDays < 15) return 'seven_to_fifteen_days'; + if (ageDays < 30) return 'fifteen_to_thirty_days'; + return 'older_than_thirty_days'; +} + +export function archiveBucketForTimestamp( + archivedAt: string | null, + nowMilliseconds = Date.now() +): ArchiveBucket { + if (!archivedAt) { + // Unknown archive ages belong in the oldest bucket. Treating them as fresh + // would make a destructive action look safer than it is. + return 'older_than_thirty_days'; + } + + const archivedAtMilliseconds = Date.parse(archivedAt); + if (!Number.isFinite(archivedAtMilliseconds)) { + return 'older_than_thirty_days'; + } + + return archiveBucketFromAgeMilliseconds( + nowMilliseconds - archivedAtMilliseconds + ); +} + +export function isArchivedRecently( + archivedAt: string | null, + nowMilliseconds = Date.now() +): boolean { + const bucket = archiveBucketForTimestamp(archivedAt, nowMilliseconds); + return bucket === 'today' || bucket === 'one_to_three_days'; +} diff --git a/packages/web-core/src/shared/hooks/useWorkspaces.ts b/packages/web-core/src/shared/hooks/useWorkspaces.ts index ac84f05f72..90c64414d0 100644 --- a/packages/web-core/src/shared/hooks/useWorkspaces.ts +++ b/packages/web-core/src/shared/hooks/useWorkspaces.ts @@ -18,7 +18,9 @@ export interface SidebarWorkspace { branch: string; createdAt: string; updatedAt: string; + archivedAt: string | null; description: string; + repoCount?: number; filesChanged?: number; linesAdded?: number; linesRemoved?: number; @@ -66,7 +68,9 @@ function toSidebarWorkspace( branch: ws.branch, createdAt: ws.created_at, updatedAt: ws.updated_at, + archivedAt: ws.archived_at, description: '', + repoCount: summary?.repo_count, // Use real stats from summary if available filesChanged: summary?.files_changed ?? undefined, linesAdded: summary?.lines_added ?? undefined, diff --git a/packages/web-core/src/shared/lib/api.ts b/packages/web-core/src/shared/lib/api.ts index 765dd711ea..84c8c59250 100644 --- a/packages/web-core/src/shared/lib/api.ts +++ b/packages/web-core/src/shared/lib/api.ts @@ -109,6 +109,8 @@ import { UpsertLoopAutomationRequest, ScheduledWakeup, CreateWakeupRequest, + BulkDeleteArchivedWorkspacesRequest, + BulkDeleteArchivedWorkspacesResponse, } from 'shared/types'; import type { WorkspaceWithSession } from '@/shared/types/attempt'; import { createWorkspaceWithSession } from '@/shared/types/attempt'; @@ -517,6 +519,16 @@ export const workspacesApi = { return handleApiResponse(response); }, + bulkDeleteArchived: async ( + data: BulkDeleteArchivedWorkspacesRequest + ): Promise => { + const response = await makeRequest('/api/workspaces/archived/bulk-delete', { + method: 'POST', + body: JSON.stringify(data), + }); + return handleApiResponse(response); + }, + linkToIssue: async ( workspaceId: string, projectId: string, diff --git a/packages/web-core/src/shared/lib/archiveBuckets.test.ts b/packages/web-core/src/shared/lib/archiveBuckets.test.ts new file mode 100644 index 0000000000..12de34309d --- /dev/null +++ b/packages/web-core/src/shared/lib/archiveBuckets.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + DAY_IN_MILLISECONDS, + archiveBucketForTimestamp, + archiveBucketFromAgeMilliseconds, + isArchivedRecently, +} from './archiveBuckets'; + +const millisecond = 1; + +describe('archiveBucketFromAgeMilliseconds', () => { + it.each([ + [0, 'today'], + [millisecond, 'today'], + [DAY_IN_MILLISECONDS - millisecond, 'today'], + [DAY_IN_MILLISECONDS, 'one_to_three_days'], + [DAY_IN_MILLISECONDS + millisecond, 'one_to_three_days'], + [3 * DAY_IN_MILLISECONDS - millisecond, 'one_to_three_days'], + [3 * DAY_IN_MILLISECONDS, 'three_to_seven_days'], + [3 * DAY_IN_MILLISECONDS + millisecond, 'three_to_seven_days'], + [7 * DAY_IN_MILLISECONDS - millisecond, 'three_to_seven_days'], + [7 * DAY_IN_MILLISECONDS, 'seven_to_fifteen_days'], + [7 * DAY_IN_MILLISECONDS + millisecond, 'seven_to_fifteen_days'], + [15 * DAY_IN_MILLISECONDS - millisecond, 'seven_to_fifteen_days'], + [15 * DAY_IN_MILLISECONDS, 'fifteen_to_thirty_days'], + [15 * DAY_IN_MILLISECONDS + millisecond, 'fifteen_to_thirty_days'], + [30 * DAY_IN_MILLISECONDS - millisecond, 'fifteen_to_thirty_days'], + [30 * DAY_IN_MILLISECONDS, 'older_than_thirty_days'], + [30 * DAY_IN_MILLISECONDS + millisecond, 'older_than_thirty_days'], + ] as const)('maps age %i ms to %s', (age, expected) => { + expect(archiveBucketFromAgeMilliseconds(age)).toBe(expected); + }); + + it('clamps future timestamps to today', () => { + expect(archiveBucketFromAgeMilliseconds(-10 * DAY_IN_MILLISECONDS)).toBe( + 'today' + ); + }); +}); + +describe('archiveBucketForTimestamp', () => { + const now = Date.UTC(2026, 6, 22, 12); + + it('puts missing or invalid timestamps in the defensive oldest bucket', () => { + expect(archiveBucketForTimestamp(null, now)).toBe('older_than_thirty_days'); + expect(archiveBucketForTimestamp('not-a-date', now)).toBe( + 'older_than_thirty_days' + ); + }); + + it('uses the Today plus 1–3 day buckets for Archived recently', () => { + expect( + isArchivedRecently( + new Date(now - 3 * DAY_IN_MILLISECONDS + millisecond).toISOString(), + now + ) + ).toBe(true); + expect( + isArchivedRecently( + new Date(now - 3 * DAY_IN_MILLISECONDS).toISOString(), + now + ) + ).toBe(false); + }); +}); diff --git a/packages/web-core/src/shared/lib/archiveBuckets.ts b/packages/web-core/src/shared/lib/archiveBuckets.ts new file mode 100644 index 0000000000..3b49f73a70 --- /dev/null +++ b/packages/web-core/src/shared/lib/archiveBuckets.ts @@ -0,0 +1,9 @@ +// The UI package owns the pure implementation because the presentational +// sidebar performs the grouping. Re-export it here for application callers. +export { + ARCHIVE_BUCKET_ORDER, + DAY_IN_MILLISECONDS, + archiveBucketForTimestamp, + archiveBucketFromAgeMilliseconds, + isArchivedRecently, +} from '@vibe/ui/lib/archiveBuckets'; From 6fd35e39c8182eb9bd3e8cf94f457f1dead16377 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 20:06:42 +0000 Subject: [PATCH 08/25] feat(ui): add archived bucket removal dialog --- .../BulkDeleteArchivedWorkspacesDialog.tsx | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx new file mode 100644 index 0000000000..a705a162e2 --- /dev/null +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -0,0 +1,429 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + CheckCircleIcon, + FolderOpenIcon, + GitBranchIcon, + SpinnerIcon, + WarningCircleIcon, + WarningIcon, + XCircleIcon, +} from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { Button } from './Button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './KeyboardDialog'; + +export interface BulkDeleteDialogWorkspace { + id: string; + name: string; + repoCount?: number; +} + +export interface BulkDeleteDialogBranchStatus { + commitsAhead: number | null; +} + +export type BulkDeleteDialogItemOutcome = + | { status: 'deleted' } + | { status: 'skipped'; reason: string } + | { status: 'failed'; reason: string }; + +export interface BulkDeleteDialogItemResult { + workspaceId: string; + workspaceName: string | null; + outcome: BulkDeleteDialogItemOutcome; +} + +export interface BulkDeleteArchivedWorkspacesDialogProps { + open: boolean; + bucketLabel: string; + workspaces: BulkDeleteDialogWorkspace[]; + onOpenChange: (open: boolean) => void; + inspectWorkspace: ( + workspaceId: string + ) => Promise; + onConfirm: () => Promise; +} + +interface InspectionSummary { + branchCount: number; + worktreeCount: number; + unmergedBranchCount: number; + unmergedWorkspaceCount: number; +} + +function OutcomeIcon({ + status, +}: { + status: BulkDeleteDialogItemOutcome['status']; +}) { + if (status === 'deleted') { + return ( + + ); + } + if (status === 'skipped') { + return ( + + ); + } + return ; +} + +export function BulkDeleteArchivedWorkspacesDialog({ + open, + bucketLabel, + workspaces, + onOpenChange, + inspectWorkspace, + onConfirm, +}: BulkDeleteArchivedWorkspacesDialogProps) { + const { t } = useTranslation('common'); + const [inspection, setInspection] = useState(null); + const [inspectionError, setInspectionError] = useState(null); + const [inspectionAttempt, setInspectionAttempt] = useState(0); + const [isInspecting, setIsInspecting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [operationError, setOperationError] = useState(null); + const [results, setResults] = useState( + null + ); + + const initialRepoCount = useMemo(() => { + if (workspaces.some((workspace) => workspace.repoCount === undefined)) { + return null; + } + return workspaces.reduce( + (count, workspace) => count + (workspace.repoCount ?? 0), + 0 + ); + }, [workspaces]); + + useEffect(() => { + if (!open) return; + + let canceled = false; + setResults(null); + setOperationError(null); + setInspection(null); + setInspectionError(null); + setIsInspecting(true); + + void Promise.all( + workspaces.map(async (workspace) => ({ + workspace, + statuses: await inspectWorkspace(workspace.id), + })) + ) + .then((workspaceStatuses) => { + if (canceled) return; + + const branchCount = workspaceStatuses.reduce( + (count, item) => count + item.statuses.length, + 0 + ); + const unmergedBranchCount = workspaceStatuses.reduce( + (count, item) => + count + + item.statuses.filter((status) => (status.commitsAhead ?? 0) > 0) + .length, + 0 + ); + const unmergedWorkspaceCount = workspaceStatuses.filter((item) => + item.statuses.some((status) => (status.commitsAhead ?? 0) > 0) + ).length; + + setInspection({ + branchCount, + worktreeCount: branchCount, + unmergedBranchCount, + unmergedWorkspaceCount, + }); + }) + .catch((error: unknown) => { + if (canceled) return; + setInspectionError( + error instanceof Error + ? error.message + : t('kanban.workspaceSidebar.bulkDelete.inspectionFailed', { + defaultValue: 'Could not inspect every branch.', + }) + ); + }) + .finally(() => { + if (!canceled) setIsInspecting(false); + }); + + return () => { + canceled = true; + }; + }, [inspectWorkspace, inspectionAttempt, open, t, workspaces]); + + const resultCounts = useMemo(() => { + if (!results) return null; + return results.reduce( + (counts, result) => { + counts[result.outcome.status] += 1; + return counts; + }, + { deleted: 0, skipped: 0, failed: 0 } + ); + }, [results]); + + const handleConfirm = async () => { + if (!inspection || inspectionError || isInspecting || isDeleting) return; + + setIsDeleting(true); + setOperationError(null); + try { + setResults(await onConfirm()); + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : t('kanban.workspaceSidebar.bulkDelete.requestFailed', { + defaultValue: 'The bulk removal request failed.', + }) + ); + } finally { + setIsDeleting(false); + } + }; + + const branchCount = inspection?.branchCount ?? initialRepoCount; + const worktreeCount = inspection?.worktreeCount ?? initialRepoCount; + + return ( + { + if (!isDeleting) onOpenChange(nextOpen); + }} + uncloseable={isDeleting} + > + + +
+ + + {results + ? t('kanban.workspaceSidebar.bulkDelete.resultsTitle', { + defaultValue: 'Removal results', + }) + : t('kanban.workspaceSidebar.bulkDelete.title', { + defaultValue: 'Remove archived workspaces', + })} + +
+ + {results + ? t('kanban.workspaceSidebar.bulkDelete.resultsDescription', { + defaultValue: + 'Each workspace is reported separately so skipped or failed removals are visible.', + }) + : t('kanban.workspaceSidebar.bulkDelete.description', { + bucket: bucketLabel, + defaultValue: + 'This permanently removes every workspace currently in “{{bucket}}”. This action cannot be undone.', + })} + +
+ + {results && resultCounts ? ( +
+

+ {t('kanban.workspaceSidebar.bulkDelete.resultSummary', { + deleted: resultCounts.deleted, + skipped: resultCounts.skipped, + failed: resultCounts.failed, + defaultValue: + '{{deleted}} removed, {{skipped}} skipped, {{failed}} failed', + })} +

+
+ {results.map((result) => ( +
+ +
+
+ + {result.workspaceName ?? result.workspaceId} + + + {result.outcome.status} + +
+ {'reason' in result.outcome && ( +

+ {result.outcome.reason} +

+ )} +
+
+ ))} +
+
+ ) : ( +
{ + event.preventDefault(); + void handleConfirm(); + }} + > +
+
+
+ {t('kanban.workspaceSidebar.bulkDelete.workspaces', { + defaultValue: 'Workspaces', + })} +
+
+ {workspaces.length} +
+
+ + {t('kanban.workspaceSidebar.bulkDelete.branches', { + defaultValue: 'Branches deleted', + })} +
+
+ {branchCount ?? '—'} +
+
+ + {t('kanban.workspaceSidebar.bulkDelete.worktrees', { + defaultValue: 'Worktrees removed', + })} +
+
+ {worktreeCount ?? '—'} +
+
+ {t('kanban.workspaceSidebar.bulkDelete.unmerged', { + defaultValue: 'Branches with unmerged commits', + })} +
+
+ {isInspecting ? ( + + + {t('kanban.workspaceSidebar.bulkDelete.inspecting', { + defaultValue: 'Inspecting…', + })} + + ) : inspection ? ( + + {inspection.unmergedBranchCount} + + {t( + 'kanban.workspaceSidebar.bulkDelete.unmergedWorkspaces', + { + count: inspection.unmergedWorkspaceCount, + defaultValue: 'across {{count}} workspace(s)', + } + )} + + + ) : ( + '—' + )} +
+
+
+ + {inspectionError && ( +
+ {inspectionError} + +
+ )} + +
+

+ {t('kanban.workspaceSidebar.bulkDelete.targets', { + defaultValue: 'Workspaces that will be removed', + })} +

+
    + {workspaces.map((workspace) => ( +
  • + {workspace.name} +
  • + ))} +
+
+ + {operationError && ( +

+ {operationError} +

+ )} + + + + + +
+ )} + + {results && ( + + + + )} +
+
+ ); +} From 381074e7813b5f659d59bd87aafbded4fd792042 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 20:12:04 +0000 Subject: [PATCH 09/25] feat(web): group archived workspaces for bulk removal --- .../components/CollapsibleSectionHeader.tsx | 106 ++++------- .../ui/src/components/WorkspacesSidebar.tsx | 176 +++++++++++++++--- .../workspaces/WorkspacesSidebarContainer.tsx | 51 ++++- 3 files changed, 240 insertions(+), 93 deletions(-) diff --git a/packages/ui/src/components/CollapsibleSectionHeader.tsx b/packages/ui/src/components/CollapsibleSectionHeader.tsx index f6067a45ea..e0b72dbcde 100644 --- a/packages/ui/src/components/CollapsibleSectionHeader.tsx +++ b/packages/ui/src/components/CollapsibleSectionHeader.tsx @@ -1,4 +1,4 @@ -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import type { Icon } from '@phosphor-icons/react'; import { CaretDownIcon } from '@phosphor-icons/react'; @@ -26,6 +26,7 @@ export type SectionAction = { icon: Icon; onClick: () => void; isActive?: boolean; + ariaLabel?: string; }; interface CollapsibleSectionHeaderProps { @@ -69,84 +70,55 @@ export function CollapsibleSectionHeader({ } }, [persistKey, expanded]); - const handleActionClick = ( - e: MouseEvent, - onClick: () => void - ) => { - e.stopPropagation(); - onClick(); - }; - - const handleActionKeyDown = ( - e: KeyboardEvent, - onClick: () => void - ) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - e.preventDefault(); - e.stopPropagation(); - onClick(); - }; - const isExpanded = collapsible ? expanded : true; - const headerContent = ( - <> - {title} -
- {headerExtra} - {actions.map((action, index) => { - const ActionIcon = action.icon; - return ( - handleActionClick(e, action.onClick)} - onKeyDown={(e) => handleActionKeyDown(e, action.onClick)} - className={cn( - 'hover:text-normal', - action.isActive ? 'text-brand' : 'text-low' - )} - > - - - ); - })} - {collapsible && ( - - )} -
- - ); - return (
-
+
{collapsible ? ( ) : ( -
- {headerContent} -
+ + {title} + )} +
+ {headerExtra} + {actions.map((action, index) => { + const ActionIcon = action.icon; + return ( + + ); + })} +
{isExpanded && children}
diff --git a/packages/ui/src/components/WorkspacesSidebar.tsx b/packages/ui/src/components/WorkspacesSidebar.tsx index 0119680863..5a9570f725 100644 --- a/packages/ui/src/components/WorkspacesSidebar.tsx +++ b/packages/ui/src/components/WorkspacesSidebar.tsx @@ -1,14 +1,21 @@ import type { ReactNode } from 'react'; -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { PlusIcon, ArrowLeftIcon, ArchiveIcon, + DotsThreeIcon, StackIcon, SpinnerIcon, + TrashIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; +import type { ArchiveBucket } from 'shared/types'; import { cn } from '../lib/cn'; +import { + ARCHIVE_BUCKET_ORDER, + archiveBucketForTimestamp, +} from '../lib/archiveBuckets'; import { InputField } from './InputField'; import { WorkspaceSummary } from './WorkspaceSummary'; export type WorkspacesSidebarHostStatus = 'online' | 'offline' | 'unpaired'; @@ -16,9 +23,29 @@ import { CollapsibleSectionHeader, type SectionAction, } from './CollapsibleSectionHeader'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './DropdownMenu'; +import { + BulkDeleteArchivedWorkspacesDialog, + type BulkDeleteDialogBranchStatus, + type BulkDeleteDialogItemResult, +} from './BulkDeleteArchivedWorkspacesDialog'; export type WorkspaceLayoutMode = 'flat' | 'accordion'; +const ARCHIVE_BUCKET_LABELS: Record = { + today: 'Today', + one_to_three_days: '1–3 days', + three_to_seven_days: '3–7 days', + seven_to_fifteen_days: '7–15 days', + fifteen_to_thirty_days: '15–30 days', + older_than_thirty_days: 'Older than 30 days', +}; + export interface WorkspacesSidebarWorkspace { id: string; name: string; @@ -53,6 +80,7 @@ export interface WorkspacesSidebarProps { workspaces: WorkspacesSidebarWorkspace[]; totalWorkspacesCount: number; archivedWorkspaces?: WorkspacesSidebarWorkspace[]; + visibleArchivedWorkspaceIds?: ReadonlySet; isLoading?: boolean; selectedWorkspaceId: string | null; onSelectWorkspace: (id: string) => void; @@ -81,6 +109,14 @@ export interface WorkspacesSidebarProps { searchControls?: ReactNode; /** Callback for opening workspace actions */ onOpenWorkspaceActions?: (workspaceId: string) => void; + /** Loads current branch status before a destructive bucket removal. */ + inspectArchivedWorkspace?: ( + workspaceId: string + ) => Promise; + /** Permanently removes the server-resolved contents of an archive bucket. */ + onBulkDeleteArchivedBucket?: ( + bucket: ArchiveBucket + ) => Promise; /** Persist keys for collapsible sections */ persistKeys?: WorkspacesSidebarPersistKeys; activeRemoteHost?: { @@ -172,6 +208,7 @@ export function WorkspacesSidebar({ workspaces, totalWorkspacesCount, archivedWorkspaces = [], + visibleArchivedWorkspaceIds, isLoading = false, selectedWorkspaceId, onSelectWorkspace, @@ -189,6 +226,8 @@ export function WorkspacesSidebar({ hasMoreWorkspaces = false, searchControls, onOpenWorkspaceActions, + inspectArchivedWorkspace, + onBulkDeleteArchivedBucket, persistKeys = DEFAULT_PERSIST_KEYS, activeRemoteHost = null, onOpenRemoteHostSettings, @@ -234,15 +273,42 @@ export function WorkspacesSidebar({ }; }, [workspaces]); + const archivedBuckets = useMemo(() => { + const nowMilliseconds = Date.now(); + return ARCHIVE_BUCKET_ORDER.map((bucket) => ({ + bucket, + workspaces: archivedWorkspaces.filter( + (workspace) => + archiveBucketForTimestamp(workspace.archivedAt, nowMilliseconds) === + bucket + ), + })).filter((group) => group.workspaces.length > 0); + }, [archivedWorkspaces]); + + const [bulkDeleteTarget, setBulkDeleteTarget] = useState<{ + bucket: ArchiveBucket; + label: string; + workspaces: WorkspacesSidebarWorkspace[]; + } | null>(null); + + const getArchiveBucketLabel = (bucket: ArchiveBucket) => + t(`common:workspaces.archiveBuckets.${bucket}`, { + defaultValue: ARCHIVE_BUCKET_LABELS[bucket], + }); + const headerActions: SectionAction[] = [ { icon: StackIcon, onClick: () => onToggleLayoutMode?.(), isActive: layoutMode === 'accordion', + ariaLabel: t('common:workspaces.toggleLayout', { + defaultValue: 'Toggle workspace layout', + }), }, { icon: PlusIcon, onClick: () => onAddWorkspace?.(), + ariaLabel: t('common:workspaces.newWorkspace'), }, ]; @@ -330,33 +396,79 @@ export function WorkspacesSidebar({ {t('common:workspaces.archived')} - {archivedWorkspaces.length === 0 ? ( + {archivedBuckets.length === 0 ? ( {t('common:workspaces.noArchived')} ) : ( - archivedWorkspaces.map((workspace) => ( - onSelectWorkspace(workspace.id)} - /> - )) + archivedBuckets.map((group) => { + const label = getArchiveBucketLabel(group.bucket); + return ( + + + + + + + setBulkDeleteTarget({ + bucket: group.bucket, + label, + workspaces: [...group.workspaces], + }) + } + > + + {t('common:workspaces.removeArchiveBucket', { + defaultValue: 'Remove all in this bucket', + })} + + + + ) : null + } + > +
+ {group.workspaces + .filter( + (workspace) => + !visibleArchivedWorkspaceIds || + visibleArchivedWorkspaceIds.has(workspace.id) + ) + .map((workspace) => ( + onSelectWorkspace(workspace.id)} + /> + ))} +
+
+ ); + }) )}
) : layoutMode === 'accordion' ? ( @@ -500,6 +612,22 @@ export function WorkspacesSidebar({ )} + {bulkDeleteTarget && + inspectArchivedWorkspace && + onBulkDeleteArchivedBucket && ( + + onBulkDeleteArchivedBucket(bulkDeleteTarget.bucket) + } + onOpenChange={(open) => { + if (!open) setBulkDeleteTarget(null); + }} + /> + )} ); } diff --git a/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx b/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx index 81a2e6cc4b..db59e0ce20 100644 --- a/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx +++ b/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx @@ -3,7 +3,11 @@ import { useParams } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext'; import { useScratch } from '@/shared/hooks/useScratch'; -import { ScratchType, type DraftWorkspaceData } from 'shared/types'; +import { + ScratchType, + type ArchiveBucket, + type DraftWorkspaceData, +} from 'shared/types'; import { splitMessageToTitleDescription } from '@/shared/lib/string'; import { cn } from '@/shared/lib/utils'; import { useIsMobile } from '@/shared/hooks/useIsMobile'; @@ -22,6 +26,10 @@ import { WorkspacesSidebar, type WorkspacesSidebarPersistKeys, } from '@vibe/ui/components/WorkspacesSidebar'; +import type { + BulkDeleteDialogBranchStatus, + BulkDeleteDialogItemResult, +} from '@vibe/ui/components/BulkDeleteArchivedWorkspacesDialog'; import { PropertyDropdown } from '@vibe/ui/components/PropertyDropdown'; import { PrimaryButton } from '@vibe/ui/components/PrimaryButton'; import { IconButton } from '@vibe/ui/components/IconButton'; @@ -44,6 +52,7 @@ import { XIcon, } from '@phosphor-icons/react'; import { useRemoteCloudHostsAppBarModel } from '@/shared/hooks/useRemoteCloudHosts'; +import { workspacesApi } from '@/shared/lib/api'; export type WorkspaceLayoutMode = 'flat' | 'accordion'; @@ -381,6 +390,11 @@ export function WorkspacesSidebarContainer({ [filteredArchivedWorkspaces, sortWorkspaces] ); + const allSortedArchivedWorkspaces = useMemo( + () => sortWorkspaces(archivedWorkspaces), + [archivedWorkspaces, sortWorkspaces] + ); + // Apply pagination (only when not searching) const paginatedActiveWorkspaces = useMemo( () => @@ -398,6 +412,11 @@ export function WorkspacesSidebarContainer({ [sortedArchivedWorkspaces, displayLimit, isSearching] ); + const visibleArchivedWorkspaceIds = useMemo( + () => new Set(paginatedArchivedWorkspaces.map((workspace) => workspace.id)), + [paginatedArchivedWorkspaces] + ); + // Check if there are more workspaces to load const hasMoreWorkspaces = showArchive ? sortedArchivedWorkspaces.length > displayLimit @@ -465,6 +484,31 @@ export function WorkspacesSidebarContainer({ }); }, []); + const inspectArchivedWorkspace = useCallback( + async (workspaceId: string): Promise => { + const statuses = await workspacesApi.getBranchStatus(workspaceId); + return statuses.map((status) => ({ + commitsAhead: status.commits_ahead, + })); + }, + [] + ); + + const bulkDeleteArchivedBucket = useCallback( + async (bucket: ArchiveBucket): Promise => { + const response = await workspacesApi.bulkDeleteArchived({ + bucket, + delete_branches: true, + }); + return response.results.map((result) => ({ + workspaceId: result.workspace_id, + workspaceName: result.workspace_name, + outcome: result.outcome, + })); + }, + [] + ); + const sidebarPersistKeys: WorkspacesSidebarPersistKeys = { raisedHand: PERSIST_KEYS.workspacesSidebarRaisedHand, notRunning: PERSIST_KEYS.workspacesSidebarNotRunning, @@ -540,7 +584,8 @@ export function WorkspacesSidebarContainer({ Date: Wed, 22 Jul 2026 20:14:42 +0000 Subject: [PATCH 10/25] feat(web): add recently archived quick restore --- .../components/CollapsibleSectionHeader.tsx | 16 +- .../ui/src/components/WorkspaceSummary.tsx | 53 ++-- .../ui/src/components/WorkspacesSidebar.tsx | 251 +++++++++++------- .../workspaces/WorkspacesSidebarContainer.tsx | 25 ++ 4 files changed, 224 insertions(+), 121 deletions(-) diff --git a/packages/ui/src/components/CollapsibleSectionHeader.tsx b/packages/ui/src/components/CollapsibleSectionHeader.tsx index e0b72dbcde..a26e859faa 100644 --- a/packages/ui/src/components/CollapsibleSectionHeader.tsx +++ b/packages/ui/src/components/CollapsibleSectionHeader.tsx @@ -38,6 +38,7 @@ interface CollapsibleSectionHeaderProps { headerExtra?: ReactNode; children?: ReactNode; className?: string; + titleClassName?: string; } export function CollapsibleSectionHeader({ @@ -49,6 +50,7 @@ export function CollapsibleSectionHeader({ headerExtra, children, className, + titleClassName, }: CollapsibleSectionHeaderProps) { const [expanded, setExpanded] = useState(() => getInitialExpanded(persistKey, defaultExpanded) @@ -82,7 +84,12 @@ export function CollapsibleSectionHeader({ aria-expanded={expanded} className="flex min-w-0 flex-1 cursor-pointer items-center justify-between py-half pr-half text-left" > - + {title} ) : ( - + {title} )} diff --git a/packages/ui/src/components/WorkspaceSummary.tsx b/packages/ui/src/components/WorkspaceSummary.tsx index 9ec0e2c0c0..985d78686f 100644 --- a/packages/ui/src/components/WorkspaceSummary.tsx +++ b/packages/ui/src/components/WorkspaceSummary.tsx @@ -45,6 +45,9 @@ export interface WorkspaceSummaryProps { onClick?: () => void; className?: string; summary?: boolean; + summaryTimestamp?: string; + mutedSummary?: boolean; + actionLabel?: string; /** Whether this is a draft workspace (shows "Draft" instead of elapsed time) */ isDraft?: boolean; onOpenWorkspaceActions?: (workspaceId: string) => void; @@ -68,6 +71,9 @@ export function WorkspaceSummary({ onClick, className, summary = false, + summaryTimestamp, + mutedSummary = false, + actionLabel, isDraft = false, onOpenWorkspaceActions, }: WorkspaceSummaryProps) { @@ -98,28 +104,41 @@ export function WorkspaceSummary({ )} /> @@ -362,7 +362,7 @@ export function BulkDeleteArchivedWorkspacesDialog({

- {t('kanban.workspaceSidebar.bulkDelete.targets', { + {t('kanban.workspaceSidebar.bulkDeleteTargets', { defaultValue: 'Workspaces that will be removed', })}

@@ -404,10 +404,10 @@ export function BulkDeleteArchivedWorkspacesDialog({ )} {isDeleting - ? t('kanban.workspaceSidebar.bulkDelete.removing', { + ? t('kanban.workspaceSidebar.bulkDeleteRemoving', { defaultValue: 'Removing…', }) - : t('kanban.workspaceSidebar.bulkDelete.confirm', { + : t('kanban.workspaceSidebar.bulkDeleteConfirm', { count: workspaces.length, defaultValue: 'Remove {{count}} workspaces', })} diff --git a/packages/ui/src/components/WorkspacesSidebar.tsx b/packages/ui/src/components/WorkspacesSidebar.tsx index fbab3b6e01..4c381ce82d 100644 --- a/packages/ui/src/components/WorkspacesSidebar.tsx +++ b/packages/ui/src/components/WorkspacesSidebar.tsx @@ -46,6 +46,15 @@ const ARCHIVE_BUCKET_LABELS: Record = { older_than_thirty_days: 'Older than 30 days', }; +const ARCHIVE_BUCKET_TRANSLATION_KEYS: Record = { + today: 'common:workspaces.archiveBucketToday', + one_to_three_days: 'common:workspaces.archiveBucketOneToThreeDays', + three_to_seven_days: 'common:workspaces.archiveBucketThreeToSevenDays', + seven_to_fifteen_days: 'common:workspaces.archiveBucketSevenToFifteenDays', + fifteen_to_thirty_days: 'common:workspaces.archiveBucketFifteenToThirtyDays', + older_than_thirty_days: 'common:workspaces.archiveBucketOlderThanThirtyDays', +}; + export interface WorkspacesSidebarWorkspace { id: string; name: string; @@ -297,7 +306,7 @@ export function WorkspacesSidebar({ } | null>(null); const getArchiveBucketLabel = (bucket: ArchiveBucket) => - t(`common:workspaces.archiveBuckets.${bucket}`, { + t(ARCHIVE_BUCKET_TRANSLATION_KEYS[bucket], { defaultValue: ARCHIVE_BUCKET_LABELS[bucket], }); diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index f1198c3daa..a3a8a2c932 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -213,6 +213,17 @@ "unpin": "Unpin", "archive": "Archive", "more": "More actions", + "toggleLayout": "Toggle workspace layout", + "archiveBucketToday": "Today", + "archiveBucketOneToThreeDays": "1–3 days", + "archiveBucketThreeToSevenDays": "3–7 days", + "archiveBucketSevenToFifteenDays": "7–15 days", + "archiveBucketFifteenToThirtyDays": "15–30 days", + "archiveBucketOlderThanThirtyDays": "Older than 30 days", + "archiveBucketActions": "Actions for {{bucket}}", + "removeArchiveBucket": "Remove all in this bucket", + "archivedRecentlyCount": "Archived recently ({{count}})", + "restoreArchivedWorkspace": "Restore {{workspace}}", "rename": { "title": "Rename Workspace", "description": "Enter a new name for this workspace.", @@ -496,7 +507,24 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "Remove archived workspaces", + "bulkDeleteResultsTitle": "Removal results", + "bulkDeleteResultsDescription": "Each workspace is reported separately so skipped or failed removals are visible.", + "bulkDeleteDescription": "This permanently removes every workspace currently in “{{bucket}}”. This action cannot be undone.", + "bulkDeleteResultSummary": "{{deleted}} removed, {{skipped}} skipped, {{failed}} failed", + "bulkDeleteWorkspaces": "Workspaces", + "bulkDeleteBranches": "Branches deleted", + "bulkDeleteWorktrees": "Worktrees removed", + "bulkDeleteUnmerged": "Branches with unmerged commits", + "bulkDeleteInspecting": "Inspecting…", + "bulkDeleteUnmergedWorkspaces": "across {{count}} workspace(s)", + "bulkDeleteInspectionFailed": "Could not inspect every branch.", + "bulkDeleteRequestFailed": "The bulk removal request failed.", + "bulkDeleteRetry": "Retry", + "bulkDeleteTargets": "Workspaces that will be removed", + "bulkDeleteRemoving": "Removing…", + "bulkDeleteConfirm": "Remove {{count}} workspaces" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index f04f74c00f..0f4e284ee3 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -199,6 +199,17 @@ "unpin": "Desfijar", "archive": "Archivar", "more": "Más acciones", + "toggleLayout": "Cambiar diseño de los espacios de trabajo", + "archiveBucketToday": "Hoy", + "archiveBucketOneToThreeDays": "1–3 días", + "archiveBucketThreeToSevenDays": "3–7 días", + "archiveBucketSevenToFifteenDays": "7–15 días", + "archiveBucketFifteenToThirtyDays": "15–30 días", + "archiveBucketOlderThanThirtyDays": "Más de 30 días", + "archiveBucketActions": "Acciones para {{bucket}}", + "removeArchiveBucket": "Eliminar todos los de este grupo", + "archivedRecentlyCount": "Archivados recientemente ({{count}})", + "restoreArchivedWorkspace": "Restaurar {{workspace}}", "rename": { "title": "Renombrar espacio de trabajo", "description": "Ingresa un nuevo nombre para este espacio de trabajo.", @@ -482,7 +493,24 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "Eliminar espacios de trabajo archivados", + "bulkDeleteResultsTitle": "Resultados de eliminación", + "bulkDeleteResultsDescription": "Cada espacio de trabajo se informa por separado para mostrar las eliminaciones omitidas o fallidas.", + "bulkDeleteDescription": "Esto elimina permanentemente todos los espacios de trabajo que estén en «{{bucket}}». Esta acción no se puede deshacer.", + "bulkDeleteResultSummary": "{{deleted}} eliminados, {{skipped}} omitidos, {{failed}} fallidos", + "bulkDeleteWorkspaces": "Espacios de trabajo", + "bulkDeleteBranches": "Ramas que se eliminarán", + "bulkDeleteWorktrees": "Árboles de trabajo que se eliminarán", + "bulkDeleteUnmerged": "Ramas con commits sin fusionar", + "bulkDeleteInspecting": "Inspeccionando…", + "bulkDeleteUnmergedWorkspaces": "en {{count}} espacio(s) de trabajo", + "bulkDeleteInspectionFailed": "No se pudieron inspeccionar todas las ramas.", + "bulkDeleteRequestFailed": "La solicitud de eliminación masiva falló.", + "bulkDeleteRetry": "Reintentar", + "bulkDeleteTargets": "Espacios de trabajo que se eliminarán", + "bulkDeleteRemoving": "Eliminando…", + "bulkDeleteConfirm": "Eliminar {{count}} espacios de trabajo" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 5d0edf3aa0..980b25fe7b 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -199,6 +199,17 @@ "unpin": "Désépingler", "archive": "Archiver", "more": "Plus d'actions", + "toggleLayout": "Changer la disposition des espaces de travail", + "archiveBucketToday": "Aujourd’hui", + "archiveBucketOneToThreeDays": "1–3 jours", + "archiveBucketThreeToSevenDays": "3–7 jours", + "archiveBucketSevenToFifteenDays": "7–15 jours", + "archiveBucketFifteenToThirtyDays": "15–30 jours", + "archiveBucketOlderThanThirtyDays": "Plus de 30 jours", + "archiveBucketActions": "Actions pour {{bucket}}", + "removeArchiveBucket": "Tout supprimer dans ce groupe", + "archivedRecentlyCount": "Archivés récemment ({{count}})", + "restoreArchivedWorkspace": "Restaurer {{workspace}}", "rename": { "title": "Renommer l'espace de travail", "description": "Saisissez un nouveau nom pour cet espace de travail.", @@ -482,7 +493,24 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "Supprimer les espaces de travail archivés", + "bulkDeleteResultsTitle": "Résultats de la suppression", + "bulkDeleteResultsDescription": "Chaque espace de travail est signalé séparément afin que les suppressions ignorées ou échouées restent visibles.", + "bulkDeleteDescription": "Cette action supprime définitivement tous les espaces de travail actuellement dans « {{bucket}} ». Elle est irréversible.", + "bulkDeleteResultSummary": "{{deleted}} supprimés, {{skipped}} ignorés, {{failed}} échoués", + "bulkDeleteWorkspaces": "Espaces de travail", + "bulkDeleteBranches": "Branches supprimées", + "bulkDeleteWorktrees": "Arborescences de travail supprimées", + "bulkDeleteUnmerged": "Branches avec des commits non fusionnés", + "bulkDeleteInspecting": "Analyse…", + "bulkDeleteUnmergedWorkspaces": "dans {{count}} espace(s) de travail", + "bulkDeleteInspectionFailed": "Impossible d’analyser toutes les branches.", + "bulkDeleteRequestFailed": "La demande de suppression groupée a échoué.", + "bulkDeleteRetry": "Réessayer", + "bulkDeleteTargets": "Espaces de travail qui seront supprimés", + "bulkDeleteRemoving": "Suppression…", + "bulkDeleteConfirm": "Supprimer {{count}} espaces de travail" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index 9fb1313b62..8becc21a14 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -199,6 +199,17 @@ "unpin": "ピン留め解除", "archive": "アーカイブ", "more": "その他の操作", + "toggleLayout": "ワークスペースの表示を切り替え", + "archiveBucketToday": "今日", + "archiveBucketOneToThreeDays": "1~3日前", + "archiveBucketThreeToSevenDays": "3~7日前", + "archiveBucketSevenToFifteenDays": "7~15日前", + "archiveBucketFifteenToThirtyDays": "15~30日前", + "archiveBucketOlderThanThirtyDays": "30日より前", + "archiveBucketActions": "{{bucket}}の操作", + "removeArchiveBucket": "このグループをすべて削除", + "archivedRecentlyCount": "最近アーカイブ ({{count}})", + "restoreArchivedWorkspace": "{{workspace}}を復元", "rename": { "title": "ワークスペースの名前を変更", "description": "このワークスペースの新しい名前を入力してください。", @@ -482,7 +493,24 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "アーカイブ済みワークスペースを削除", + "bulkDeleteResultsTitle": "削除結果", + "bulkDeleteResultsDescription": "スキップまたは失敗した削除を確認できるよう、ワークスペースごとに結果を表示します。", + "bulkDeleteDescription": "「{{bucket}}」にあるすべてのワークスペースを完全に削除します。この操作は元に戻せません。", + "bulkDeleteResultSummary": "削除 {{deleted}}件、スキップ {{skipped}}件、失敗 {{failed}}件", + "bulkDeleteWorkspaces": "ワークスペース", + "bulkDeleteBranches": "削除されるブランチ", + "bulkDeleteWorktrees": "削除されるワークツリー", + "bulkDeleteUnmerged": "未マージコミットがあるブランチ", + "bulkDeleteInspecting": "確認中…", + "bulkDeleteUnmergedWorkspaces": "{{count}}件のワークスペース", + "bulkDeleteInspectionFailed": "すべてのブランチを確認できませんでした。", + "bulkDeleteRequestFailed": "一括削除リクエストに失敗しました。", + "bulkDeleteRetry": "再試行", + "bulkDeleteTargets": "削除されるワークスペース", + "bulkDeleteRemoving": "削除中…", + "bulkDeleteConfirm": "{{count}}件のワークスペースを削除" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index a33e31fbb9..e05d682e52 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -199,6 +199,17 @@ "unpin": "고정 해제", "archive": "보관", "more": "더 많은 작업", + "toggleLayout": "워크스페이스 레이아웃 전환", + "archiveBucketToday": "오늘", + "archiveBucketOneToThreeDays": "1~3일", + "archiveBucketThreeToSevenDays": "3~7일", + "archiveBucketSevenToFifteenDays": "7~15일", + "archiveBucketFifteenToThirtyDays": "15~30일", + "archiveBucketOlderThanThirtyDays": "30일 초과", + "archiveBucketActions": "{{bucket}} 작업", + "removeArchiveBucket": "이 구간 모두 삭제", + "archivedRecentlyCount": "최근 보관됨 ({{count}})", + "restoreArchivedWorkspace": "{{workspace}} 복원", "rename": { "title": "워크스페이스 이름 변경", "description": "이 워크스페이스의 새 이름을 입력하세요.", @@ -482,7 +493,24 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "보관된 워크스페이스 삭제", + "bulkDeleteResultsTitle": "삭제 결과", + "bulkDeleteResultsDescription": "건너뛰거나 실패한 삭제를 확인할 수 있도록 워크스페이스별 결과를 표시합니다.", + "bulkDeleteDescription": "현재 “{{bucket}}”에 있는 모든 워크스페이스를 영구 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "bulkDeleteResultSummary": "{{deleted}}개 삭제, {{skipped}}개 건너뜀, {{failed}}개 실패", + "bulkDeleteWorkspaces": "워크스페이스", + "bulkDeleteBranches": "삭제할 브랜치", + "bulkDeleteWorktrees": "제거할 워크트리", + "bulkDeleteUnmerged": "병합되지 않은 커밋이 있는 브랜치", + "bulkDeleteInspecting": "검사 중…", + "bulkDeleteUnmergedWorkspaces": "{{count}}개 워크스페이스", + "bulkDeleteInspectionFailed": "모든 브랜치를 검사하지 못했습니다.", + "bulkDeleteRequestFailed": "대량 삭제 요청에 실패했습니다.", + "bulkDeleteRetry": "다시 시도", + "bulkDeleteTargets": "삭제될 워크스페이스", + "bulkDeleteRemoving": "삭제 중…", + "bulkDeleteConfirm": "워크스페이스 {{count}}개 삭제" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index d12e4454d1..64434f5043 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -199,6 +199,17 @@ "unpin": "取消置顶", "archive": "归档", "more": "更多操作", + "toggleLayout": "切换工作区布局", + "archiveBucketToday": "今天", + "archiveBucketOneToThreeDays": "1–3 天", + "archiveBucketThreeToSevenDays": "3–7 天", + "archiveBucketSevenToFifteenDays": "7–15 天", + "archiveBucketFifteenToThirtyDays": "15–30 天", + "archiveBucketOlderThanThirtyDays": "30 天以上", + "archiveBucketActions": "{{bucket}}操作", + "removeArchiveBucket": "删除此分组中的全部工作区", + "archivedRecentlyCount": "最近归档 ({{count}})", + "restoreArchivedWorkspace": "恢复 {{workspace}}", "rename": { "title": "重命名工作区", "description": "输入此工作区的新名称。", @@ -482,7 +493,24 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "删除已归档工作区", + "bulkDeleteResultsTitle": "删除结果", + "bulkDeleteResultsDescription": "逐个显示工作区结果,以便查看跳过或失败的删除。", + "bulkDeleteDescription": "这会永久删除当前位于“{{bucket}}”中的所有工作区。此操作无法撤销。", + "bulkDeleteResultSummary": "已删除 {{deleted}} 个,已跳过 {{skipped}} 个,失败 {{failed}} 个", + "bulkDeleteWorkspaces": "工作区", + "bulkDeleteBranches": "将删除的分支", + "bulkDeleteWorktrees": "将移除的工作树", + "bulkDeleteUnmerged": "包含未合并提交的分支", + "bulkDeleteInspecting": "正在检查…", + "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 个工作区", + "bulkDeleteInspectionFailed": "无法检查所有分支。", + "bulkDeleteRequestFailed": "批量删除请求失败。", + "bulkDeleteRetry": "重试", + "bulkDeleteTargets": "将删除的工作区", + "bulkDeleteRemoving": "正在删除…", + "bulkDeleteConfirm": "删除 {{count}} 个工作区" }, "sortAscending": "Ascending", "sortDescending": "Descending", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index e699141bde..10acbbc5aa 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -199,6 +199,17 @@ "unpin": "取消釘選", "archive": "封存", "more": "更多操作", + "toggleLayout": "切換工作區版面", + "archiveBucketToday": "今天", + "archiveBucketOneToThreeDays": "1–3 天", + "archiveBucketThreeToSevenDays": "3–7 天", + "archiveBucketSevenToFifteenDays": "7–15 天", + "archiveBucketFifteenToThirtyDays": "15–30 天", + "archiveBucketOlderThanThirtyDays": "30 天以上", + "archiveBucketActions": "{{bucket}}操作", + "removeArchiveBucket": "刪除此分組中的全部工作區", + "archivedRecentlyCount": "最近封存 ({{count}})", + "restoreArchivedWorkspace": "還原 {{workspace}}", "rename": { "title": "重新命名工作區", "description": "輸入此工作區的新名稱。", @@ -482,7 +493,24 @@ "prFilterHasPr": "Has PR", "prFilterNoPr": "No PR", "sortButtonTitle": "Sort workspaces", - "filterButtonTitle": "Filter workspaces" + "filterButtonTitle": "Filter workspaces", + "bulkDeleteTitle": "刪除已封存工作區", + "bulkDeleteResultsTitle": "刪除結果", + "bulkDeleteResultsDescription": "逐一顯示工作區結果,以便查看略過或失敗的刪除。", + "bulkDeleteDescription": "這會永久刪除目前位於「{{bucket}}」中的所有工作區。此操作無法復原。", + "bulkDeleteResultSummary": "已刪除 {{deleted}} 個,已略過 {{skipped}} 個,失敗 {{failed}} 個", + "bulkDeleteWorkspaces": "工作區", + "bulkDeleteBranches": "將刪除的分支", + "bulkDeleteWorktrees": "將移除的工作樹", + "bulkDeleteUnmerged": "包含未合併提交的分支", + "bulkDeleteInspecting": "正在檢查…", + "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 個工作區", + "bulkDeleteInspectionFailed": "無法檢查所有分支。", + "bulkDeleteRequestFailed": "批次刪除請求失敗。", + "bulkDeleteRetry": "重試", + "bulkDeleteTargets": "將刪除的工作區", + "bulkDeleteRemoving": "正在刪除…", + "bulkDeleteConfirm": "刪除 {{count}} 個工作區" }, "sortAscending": "Ascending", "sortDescending": "Descending", From 24cb1f0532259942e2040031d2152b6629274f63 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 20:21:37 +0000 Subject: [PATCH 12/25] fix(ui): report unavailable branch comparisons --- .../BulkDeleteArchivedWorkspacesDialog.tsx | 64 +++++++++++++++++-- .../web-core/src/i18n/locales/en/common.json | 1 + .../web-core/src/i18n/locales/es/common.json | 1 + .../web-core/src/i18n/locales/fr/common.json | 1 + .../web-core/src/i18n/locales/ja/common.json | 1 + .../web-core/src/i18n/locales/ko/common.json | 1 + .../src/i18n/locales/zh-Hans/common.json | 1 + .../src/i18n/locales/zh-Hant/common.json | 1 + 8 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx index c91741ea07..db6448500a 100644 --- a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -56,6 +56,40 @@ interface InspectionSummary { worktreeCount: number; unmergedBranchCount: number; unmergedWorkspaceCount: number; + unknownComparisonCount: number; +} + +const MAX_INSPECTION_CONCURRENCY = 8; + +async function inspectWorkspaces( + workspaces: BulkDeleteDialogWorkspace[], + inspectWorkspace: BulkDeleteArchivedWorkspacesDialogProps['inspectWorkspace'] +) { + const results: Array<{ + workspace: BulkDeleteDialogWorkspace; + statuses: BulkDeleteDialogBranchStatus[]; + }> = new Array(workspaces.length); + let nextIndex = 0; + + const workers = Array.from( + { + length: Math.min(MAX_INSPECTION_CONCURRENCY, workspaces.length), + }, + async () => { + while (nextIndex < workspaces.length) { + const index = nextIndex; + nextIndex += 1; + const workspace = workspaces[index]; + results[index] = { + workspace, + statuses: await inspectWorkspace(workspace.id), + }; + } + } + ); + + await Promise.all(workers); + return results; } function OutcomeIcon({ @@ -118,12 +152,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ setInspectionError(null); setIsInspecting(true); - void Promise.all( - workspaces.map(async (workspace) => ({ - workspace, - statuses: await inspectWorkspace(workspace.id), - })) - ) + void inspectWorkspaces(workspaces, inspectWorkspace) .then((workspaceStatuses) => { if (canceled) return; @@ -141,12 +170,20 @@ export function BulkDeleteArchivedWorkspacesDialog({ const unmergedWorkspaceCount = workspaceStatuses.filter((item) => item.statuses.some((status) => (status.commitsAhead ?? 0) > 0) ).length; + const unknownComparisonCount = workspaceStatuses.reduce( + (count, item) => + count + + item.statuses.filter((status) => status.commitsAhead === null) + .length, + 0 + ); setInspection({ branchCount, worktreeCount: branchCount, unmergedBranchCount, unmergedWorkspaceCount, + unknownComparisonCount, }); }) .catch((error: unknown) => { @@ -341,6 +378,21 @@ export function BulkDeleteArchivedWorkspacesDialog({ '—' )} + {inspection && inspection.unknownComparisonCount > 0 && ( + <> +
+ {t( + 'kanban.workspaceSidebar.bulkDeleteUnknownComparison', + { + defaultValue: 'Branches that could not be compared', + } + )} +
+
+ {inspection.unknownComparisonCount} +
+ + )}
diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index a3a8a2c932..86a7dd407d 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -517,6 +517,7 @@ "bulkDeleteBranches": "Branches deleted", "bulkDeleteWorktrees": "Worktrees removed", "bulkDeleteUnmerged": "Branches with unmerged commits", + "bulkDeleteUnknownComparison": "Branches that could not be compared", "bulkDeleteInspecting": "Inspecting…", "bulkDeleteUnmergedWorkspaces": "across {{count}} workspace(s)", "bulkDeleteInspectionFailed": "Could not inspect every branch.", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 0f4e284ee3..628ebe7997 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -503,6 +503,7 @@ "bulkDeleteBranches": "Ramas que se eliminarán", "bulkDeleteWorktrees": "Árboles de trabajo que se eliminarán", "bulkDeleteUnmerged": "Ramas con commits sin fusionar", + "bulkDeleteUnknownComparison": "Ramas que no se pudieron comparar", "bulkDeleteInspecting": "Inspeccionando…", "bulkDeleteUnmergedWorkspaces": "en {{count}} espacio(s) de trabajo", "bulkDeleteInspectionFailed": "No se pudieron inspeccionar todas las ramas.", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 980b25fe7b..7c24cfe3f0 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -503,6 +503,7 @@ "bulkDeleteBranches": "Branches supprimées", "bulkDeleteWorktrees": "Arborescences de travail supprimées", "bulkDeleteUnmerged": "Branches avec des commits non fusionnés", + "bulkDeleteUnknownComparison": "Branches qui n’ont pas pu être comparées", "bulkDeleteInspecting": "Analyse…", "bulkDeleteUnmergedWorkspaces": "dans {{count}} espace(s) de travail", "bulkDeleteInspectionFailed": "Impossible d’analyser toutes les branches.", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index 8becc21a14..e3d2c6a514 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -503,6 +503,7 @@ "bulkDeleteBranches": "削除されるブランチ", "bulkDeleteWorktrees": "削除されるワークツリー", "bulkDeleteUnmerged": "未マージコミットがあるブランチ", + "bulkDeleteUnknownComparison": "比較できなかったブランチ", "bulkDeleteInspecting": "確認中…", "bulkDeleteUnmergedWorkspaces": "{{count}}件のワークスペース", "bulkDeleteInspectionFailed": "すべてのブランチを確認できませんでした。", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index e05d682e52..f2d0b0f39f 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -503,6 +503,7 @@ "bulkDeleteBranches": "삭제할 브랜치", "bulkDeleteWorktrees": "제거할 워크트리", "bulkDeleteUnmerged": "병합되지 않은 커밋이 있는 브랜치", + "bulkDeleteUnknownComparison": "비교할 수 없는 브랜치", "bulkDeleteInspecting": "검사 중…", "bulkDeleteUnmergedWorkspaces": "{{count}}개 워크스페이스", "bulkDeleteInspectionFailed": "모든 브랜치를 검사하지 못했습니다.", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index 64434f5043..c6ea6bcb33 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -503,6 +503,7 @@ "bulkDeleteBranches": "将删除的分支", "bulkDeleteWorktrees": "将移除的工作树", "bulkDeleteUnmerged": "包含未合并提交的分支", + "bulkDeleteUnknownComparison": "无法比较的分支", "bulkDeleteInspecting": "正在检查…", "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 个工作区", "bulkDeleteInspectionFailed": "无法检查所有分支。", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 10acbbc5aa..ac987b8a87 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -503,6 +503,7 @@ "bulkDeleteBranches": "將刪除的分支", "bulkDeleteWorktrees": "將移除的工作樹", "bulkDeleteUnmerged": "包含未合併提交的分支", + "bulkDeleteUnknownComparison": "無法比較的分支", "bulkDeleteInspecting": "正在檢查…", "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 個工作區", "bulkDeleteInspectionFailed": "無法檢查所有分支。", From 62d10626be8a99363983bf8057fd425c16a43008 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 20:28:21 +0000 Subject: [PATCH 13/25] test(server): satisfy clippy in bulk deletion coverage --- crates/server/tests/bulk_delete_archived_workspaces.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/server/tests/bulk_delete_archived_workspaces.rs b/crates/server/tests/bulk_delete_archived_workspaces.rs index 5012401f51..72153bcac4 100644 --- a/crates/server/tests/bulk_delete_archived_workspaces.rs +++ b/crates/server/tests/bulk_delete_archived_workspaces.rs @@ -68,7 +68,7 @@ async fn add_running_process(deployment: &LocalDeployment, workspace_id: Uuid) { .unwrap(); } -fn result_for<'a>(results: &'a [Value], workspace_id: Uuid) -> &'a Value { +fn result_for(results: &[Value], workspace_id: Uuid) -> &Value { results .iter() .find(|result| result["workspace_id"] == workspace_id.to_string()) From a0ad67878ada2d6d66e4d7afc71db28bbf640859 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 21:51:28 +0000 Subject: [PATCH 14/25] test(frontend): add vitest runner --- package.json | 3 +- pnpm-lock.yaml | 340 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 338 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8281581663..272102b839 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "esbuild": "^0.27.2", "jwt-decode": "^4.0.0", "typescript": "^5.7.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "3.2.4" }, "engines": { "node": ">=20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 046f671f35..df30ebd0db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) packages/local-web: dependencies: @@ -1465,6 +1468,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -2586,6 +2592,9 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -2682,6 +2691,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2875,6 +2887,38 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vue/reactivity@3.5.18': resolution: {integrity: sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==} @@ -2968,6 +3012,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -3019,6 +3067,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3033,6 +3085,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3049,6 +3105,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} peerDependencies: @@ -3345,6 +3405,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3419,6 +3483,9 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -3543,10 +3610,17 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3876,6 +3950,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3979,6 +4056,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} @@ -4006,6 +4086,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@0.30.8: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} @@ -4307,6 +4390,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4693,6 +4780,9 @@ packages: shiki@3.21.0: resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4726,6 +4816,12 @@ packages: spawn-command@0.0.2: resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4749,6 +4845,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -4823,6 +4922,12 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -4831,6 +4936,18 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5002,6 +5119,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5042,6 +5164,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -5099,6 +5249,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5206,7 +5361,7 @@ snapshots: '@babel/traverse': 7.27.4 '@babel/types': 7.27.6 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5370,7 +5525,7 @@ snapshots: '@babel/parser': 7.27.5 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.1 + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5758,6 +5913,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7042,6 +7199,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -7163,6 +7325,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -7267,7 +7431,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) - debug: 4.4.1 + debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@5.9.2) optionalDependencies: @@ -7283,7 +7447,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.1 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -7401,6 +7565,52 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vue/reactivity@3.5.18': dependencies: '@vue/shared': 3.5.18 @@ -7478,6 +7688,8 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -7540,6 +7752,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.0) + cac@6.7.14: {} + callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -7548,6 +7762,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7561,6 +7783,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.1.2): dependencies: chevrotain: 11.1.2 @@ -7908,6 +8132,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} delaunator@5.0.1: @@ -7963,6 +8189,8 @@ snapshots: entities@6.0.1: {} + es-module-lexer@1.7.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -8126,8 +8354,14 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.4.0: {} + extend@3.0.2: {} fancy-ansi@0.1.3: @@ -8494,6 +8728,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -8579,6 +8815,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowlight@3.3.0: dependencies: '@types/hast': 3.0.4 @@ -8605,6 +8843,10 @@ snapshots: dependencies: react: 18.3.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@0.30.8: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -9121,6 +9363,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -9528,6 +9772,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-icons@15.18.0: {} @@ -9546,6 +9792,10 @@ snapshots: spawn-command@0.0.2: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -9573,6 +9823,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-mod@4.1.3: {} style-to-js@1.1.21: @@ -9664,6 +9918,10 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -9671,6 +9929,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9842,6 +10106,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0): dependencies: esbuild: 0.27.2 @@ -9872,6 +10157,48 @@ snapshots: tsx: 4.21.0 yaml: 2.8.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.37 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -9916,6 +10243,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: From 07b3f3ed2844d3759370e2151da5443d8a15aaa9 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 21:52:02 +0000 Subject: [PATCH 15/25] fix(workspaces): bind bulk deletion to reviewed snapshots --- crates/server/src/bin/generate_types.rs | 1 + crates/server/src/routes/workspaces/core.rs | 76 +++-- .../tests/bulk_delete_archived_workspaces.rs | 299 +++++++++++++++--- .../BulkDeleteArchivedWorkspacesDialog.tsx | 75 +++-- .../ui/src/components/WorkspacesSidebar.tsx | 148 +++++---- .../lib/bulkDeleteArchivedWorkspaces.test.ts | 47 +++ .../src/lib/bulkDeleteArchivedWorkspaces.ts | 47 +++ .../web-core/src/i18n/locales/en/common.json | 3 +- .../web-core/src/i18n/locales/es/common.json | 3 +- .../web-core/src/i18n/locales/fr/common.json | 3 +- .../web-core/src/i18n/locales/ja/common.json | 3 +- .../web-core/src/i18n/locales/ko/common.json | 3 +- .../src/i18n/locales/zh-Hans/common.json | 3 +- .../src/i18n/locales/zh-Hant/common.json | 3 +- .../workspaces/WorkspacesSidebarContainer.tsx | 9 +- shared/types.ts | 4 +- 16 files changed, 556 insertions(+), 171 deletions(-) create mode 100644 packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts create mode 100644 packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 6a1dafd619..98624cfb1b 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -145,6 +145,7 @@ fn generate_types_content() -> String { server::routes::sessions::ResetProcessRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchResponse::decl(), + server::routes::workspaces::core::BulkDeleteTarget::decl(), server::routes::workspaces::core::BulkDeleteArchivedWorkspacesRequest::decl(), server::routes::workspaces::core::BulkDeleteItemOutcome::decl(), server::routes::workspaces::core::BulkDeleteItemResult::decl(), diff --git a/crates/server/src/routes/workspaces/core.rs b/crates/server/src/routes/workspaces/core.rs index 200fc6f97e..337e7b1b4b 100644 --- a/crates/server/src/routes/workspaces/core.rs +++ b/crates/server/src/routes/workspaces/core.rs @@ -4,9 +4,8 @@ use axum::{ http::StatusCode, response::Json as ResponseJson, }; -use chrono::Utc; +use chrono::{DateTime, Utc}; use db::models::{ - archive_bucket::ArchiveBucket, coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessStatus}, workspace::{Workspace, WorkspaceError}, @@ -39,9 +38,15 @@ pub enum DeleteWorkspaceOutcome { SkippedRunningProcesses, } +#[derive(Debug, Deserialize, TS)] +pub struct BulkDeleteTarget { + pub workspace_id: Uuid, + pub archived_at: Option>, +} + #[derive(Debug, Deserialize, TS)] pub struct BulkDeleteArchivedWorkspacesRequest { - pub bucket: ArchiveBucket, + pub targets: Vec, pub delete_branches: bool, } @@ -141,25 +146,18 @@ pub async fn bulk_delete_archived_workspaces( Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; - let now = Utc::now(); - let targets = Workspace::fetch_all(pool) - .await? - .into_iter() - .filter(|workspace| workspace.archived) - .filter(|workspace| { - let bucket = workspace.archived_at.map_or( - // An archived row without a timestamp has unknown age. Keep it - // in the oldest bucket so it never appears deceptively fresh. - ArchiveBucket::OlderThanThirtyDays, - |archived_at| ArchiveBucket::from_age(now.signed_duration_since(archived_at)), - ); - bucket == request.bucket - }) - .map(|workspace| (workspace.id, workspace.name)) - .collect::>(); - - let mut results = Vec::with_capacity(targets.len()); - for (workspace_id, resolved_name) in targets { + let operation_id = Uuid::new_v4(); + let target_count = request.targets.len(); + tracing::info!( + %operation_id, + delete_branches = request.delete_branches, + target_count, + "Starting bulk archived-workspace deletion" + ); + + let mut results = Vec::with_capacity(target_count); + for target in request.targets { + let workspace_id = target.workspace_id; let fresh_workspace = match Workspace::find_by_id(pool, workspace_id).await { Ok(Some(workspace)) if !workspace.archived => { results.push(BulkDeleteItemResult { @@ -175,7 +173,7 @@ pub async fn bulk_delete_archived_workspaces( Ok(None) => { results.push(BulkDeleteItemResult { workspace_id, - workspace_name: resolved_name, + workspace_name: None, outcome: BulkDeleteItemOutcome::Skipped { reason: "already deleted".to_string(), }, @@ -185,7 +183,7 @@ pub async fn bulk_delete_archived_workspaces( Err(error) => { results.push(BulkDeleteItemResult { workspace_id, - workspace_name: resolved_name, + workspace_name: None, outcome: BulkDeleteItemOutcome::Failed { reason: error.to_string(), }, @@ -195,6 +193,18 @@ pub async fn bulk_delete_archived_workspaces( }; let workspace_name = fresh_workspace.name.clone(); + if fresh_workspace.archived_at != target.archived_at { + results.push(BulkDeleteItemResult { + workspace_id, + workspace_name, + outcome: BulkDeleteItemOutcome::Skipped { + reason: "archive state changed since review".to_string(), + }, + }); + continue; + } + + // Keep the archived guard here: the shared delete path must still delete active workspaces. let outcome = match delete_workspace_core( &deployment, fresh_workspace, @@ -218,6 +228,24 @@ pub async fn bulk_delete_archived_workspaces( }); } + let (deleted, skipped, failed) = results.iter().fold( + (0usize, 0usize, 0usize), + |(deleted, skipped, failed), result| match result.outcome { + BulkDeleteItemOutcome::Deleted => (deleted + 1, skipped, failed), + BulkDeleteItemOutcome::Skipped { .. } => (deleted, skipped + 1, failed), + BulkDeleteItemOutcome::Failed { .. } => (deleted, skipped, failed + 1), + }, + ); + tracing::info!( + %operation_id, + delete_branches = request.delete_branches, + target_count, + deleted, + skipped, + failed, + "Finished bulk archived-workspace deletion" + ); + Ok(ResponseJson(ApiResponse::success( BulkDeleteArchivedWorkspacesResponse { results }, ))) diff --git a/crates/server/tests/bulk_delete_archived_workspaces.rs b/crates/server/tests/bulk_delete_archived_workspaces.rs index 72153bcac4..5c1edb9934 100644 --- a/crates/server/tests/bulk_delete_archived_workspaces.rs +++ b/crates/server/tests/bulk_delete_archived_workspaces.rs @@ -1,3 +1,5 @@ +use std::time::Duration as StdDuration; + use axum::{ Router, body::{Body, to_bytes}, @@ -5,6 +7,7 @@ use axum::{ }; use chrono::{Duration, Utc}; use db::models::{ + archive_bucket::ArchiveBucket, session::{CreateSession, Session}, workspace::{CreateWorkspace, Workspace}, }; @@ -16,12 +19,8 @@ use tokio_util::sync::CancellationToken; use tower::ServiceExt; use uuid::Uuid; -async fn create_archived_workspace( - deployment: &LocalDeployment, - name: &str, - age: Duration, -) -> Workspace { - let workspace = Workspace::create( +async fn create_workspace(deployment: &LocalDeployment, name: &str) -> Workspace { + Workspace::create( &deployment.db().pool, &CreateWorkspace { branch: format!("test-{name}"), @@ -30,7 +29,15 @@ async fn create_archived_workspace( Uuid::new_v4(), ) .await - .unwrap(); + .unwrap() +} + +async fn create_archived_workspace( + deployment: &LocalDeployment, + name: &str, + age: Duration, +) -> Workspace { + let workspace = create_workspace(deployment, name).await; Workspace::set_archived(&deployment.db().pool, workspace.id, true) .await .unwrap(); @@ -68,6 +75,41 @@ async fn add_running_process(deployment: &LocalDeployment, workspace_id: Uuid) { .unwrap(); } +fn target(workspace_id: Uuid, archived_at: Value) -> Value { + json!({ + "workspace_id": workspace_id, + "archived_at": archived_at, + }) +} + +fn workspace_target(workspace: &Workspace) -> Value { + target(workspace.id, json!(workspace.archived_at)) +} + +async fn submit_bulk_delete(app: Router, targets: Vec) -> Vec { + let response = app + .oneshot( + Request::post("/workspaces/archived/bulk-delete") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "targets": targets, + "delete_branches": true, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["success"], true); + payload["data"]["results"].as_array().unwrap().clone() +} + fn result_for(results: &[Value], workspace_id: Uuid) -> &Value { results .iter() @@ -75,8 +117,28 @@ fn result_for(results: &[Value], workspace_id: Uuid) -> &Value { .unwrap_or_else(|| panic!("missing result for workspace {workspace_id}")) } +async fn assert_workspace_exists(deployment: &LocalDeployment, workspace_id: Uuid) { + assert!( + Workspace::find_by_id(&deployment.db().pool, workspace_id) + .await + .unwrap() + .is_some(), + "workspace {workspace_id} must remain" + ); +} + +async fn assert_workspace_deleted(deployment: &LocalDeployment, workspace_id: Uuid) { + assert!( + Workspace::find_by_id(&deployment.db().pool, workspace_id) + .await + .unwrap() + .is_none(), + "workspace {workspace_id} must be deleted" + ); +} + #[tokio::test] -async fn bulk_delete_resolves_bucket_and_reports_each_item_outcome() { +async fn bulk_delete_validates_each_submitted_snapshot_and_continues_after_failures() { let data_dir = TempDir::new().unwrap(); // This integration test is its own process and owns the environment before // LocalDeployment initializes its process-wide data-directory cache. @@ -90,67 +152,202 @@ async fn bulk_delete_resolves_bucket_and_reports_each_item_outcome() { let shutdown = CancellationToken::new(); let deployment = LocalDeployment::new(shutdown.child_token()).await.unwrap(); - let deletable = create_archived_workspace(&deployment, "deletable", Duration::days(4)).await; - let running = create_archived_workspace(&deployment, "running", Duration::days(5)).await; - let today = create_archived_workspace(&deployment, "today", Duration::hours(2)).await; - let older = create_archived_workspace(&deployment, "older", Duration::days(20)).await; - add_running_process(&deployment, running.id).await; - let app = Router::new() .merge(server::routes::workspaces::router(&deployment)) .with_state(deployment.clone()); - let response = app - .oneshot( - Request::post("/workspaces/archived/bulk-delete") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "bucket": "three_to_seven_days", - "delete_branches": true, - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let payload: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(payload["success"], true); - let results = payload["data"]["results"].as_array().unwrap(); - assert_eq!(results.len(), 2); + // Only submitted targets are considered. A same-bucket row that was not + // reviewed remains untouched, and active or running targets are skipped. + let submitted = create_archived_workspace(&deployment, "submitted", Duration::days(4)).await; + let not_submitted = + create_archived_workspace(&deployment, "not-submitted", Duration::days(4)).await; + let running = create_archived_workspace(&deployment, "running", Duration::days(5)).await; + let active = create_workspace(&deployment, "active").await; + add_running_process(&deployment, running.id).await; + + let results = submit_bulk_delete( + app.clone(), + vec![ + workspace_target(&submitted), + workspace_target(&running), + workspace_target(&active), + ], + ) + .await; + assert_eq!(results.len(), 3); assert_eq!( - result_for(results, deletable.id)["outcome"]["status"], + result_for(&results, submitted.id)["outcome"]["status"], "deleted" ); assert_eq!( - result_for(results, running.id)["outcome"]["status"], + result_for(&results, running.id)["outcome"]["status"], "skipped" ); assert!( - result_for(results, running.id)["outcome"]["reason"] + result_for(&results, running.id)["outcome"]["reason"] .as_str() .unwrap() .contains("processes are running") ); + assert_eq!( + result_for(&results, active.id)["outcome"]["reason"], + "no longer archived" + ); + assert!( + results + .iter() + .all(|result| result["workspace_id"] != not_submitted.id.to_string()), + "an unsubmitted workspace must not even receive an outcome" + ); + assert_workspace_deleted(&deployment, submitted.id).await; + for untouched_id in [not_submitted.id, running.id, active.id] { + assert_workspace_exists(&deployment, untouched_id).await; + } + // Exact archived_at equality rejects an unarchive/re-archive transition, + // but natural aging across a bucket boundary preserves the same timestamp. + let changed = create_archived_workspace(&deployment, "changed", Duration::days(4)).await; + let original_changed_timestamp = json!(changed.archived_at); + Workspace::set_archived(&deployment.db().pool, changed.id, false) + .await + .unwrap(); + tokio::time::sleep(StdDuration::from_millis(10)).await; + Workspace::set_archived(&deployment.db().pool, changed.id, true) + .await + .unwrap(); + + let aging = create_archived_workspace(&deployment, "aging", Duration::days(2)).await; + let review_now = Utc::now(); + let boundary_timestamp = review_now - Duration::days(3) + Duration::milliseconds(250); + sqlx::query("UPDATE workspaces SET archived_at = ? WHERE id = ?") + .bind(boundary_timestamp) + .bind(aging.id) + .execute(&deployment.db().pool) + .await + .unwrap(); + let aging = Workspace::find_by_id(&deployment.db().pool, aging.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + ArchiveBucket::from_age(review_now.signed_duration_since(aging.archived_at.unwrap())), + ArchiveBucket::OneToThreeDays + ); + tokio::time::sleep(StdDuration::from_millis(300)).await; + assert_eq!( + ArchiveBucket::from_age(Utc::now().signed_duration_since(aging.archived_at.unwrap())), + ArchiveBucket::ThreeToSevenDays + ); + + let null_timestamp = + create_archived_workspace(&deployment, "null-timestamp", Duration::days(40)).await; + sqlx::query("UPDATE workspaces SET archived_at = NULL WHERE id = ?") + .bind(null_timestamp.id) + .execute(&deployment.db().pool) + .await + .unwrap(); + let null_timestamp = Workspace::find_by_id(&deployment.db().pool, null_timestamp.id) + .await + .unwrap() + .unwrap(); + assert!(null_timestamp.archived); + assert_eq!(null_timestamp.archived_at, None); + + // This real HTTP request exercises JSON/serde timestamp precision for both + // Some(timestamp) and None snapshots. + let results = submit_bulk_delete( + app.clone(), + vec![ + target(changed.id, original_changed_timestamp), + workspace_target(&aging), + workspace_target(&null_timestamp), + ], + ) + .await; + assert_eq!( + result_for(&results, changed.id)["outcome"]["reason"], + "archive state changed since review" + ); + assert_eq!( + result_for(&results, aging.id)["outcome"]["status"], + "deleted" + ); + assert_eq!( + result_for(&results, null_timestamp.id)["outcome"]["status"], + "deleted" + ); + assert_workspace_exists(&deployment, changed.id).await; + assert_workspace_deleted(&deployment, aging.id).await; + assert_workspace_deleted(&deployment, null_timestamp.id).await; + + // A missing target is itemized without preventing another target from + // being deleted. + let already_deleted = + create_archived_workspace(&deployment, "already-deleted", Duration::days(8)).await; + let already_deleted_target = workspace_target(&already_deleted); + Workspace::delete(&deployment.db().pool, already_deleted.id) + .await + .unwrap(); + let after_missing = + create_archived_workspace(&deployment, "after-missing", Duration::days(8)).await; + let results = submit_bulk_delete( + app.clone(), + vec![already_deleted_target, workspace_target(&after_missing)], + ) + .await; + assert_eq!( + result_for(&results, already_deleted.id)["outcome"]["reason"], + "already deleted" + ); + assert_eq!( + result_for(&results, after_missing.id)["outcome"]["status"], + "deleted" + ); + assert_workspace_deleted(&deployment, after_missing.id).await; + + // Force one DELETE statement to fail and prove the handler still processes + // the following target. + let forced_failure = + create_archived_workspace(&deployment, "forced-failure", Duration::days(9)).await; + let after_failure = + create_archived_workspace(&deployment, "after-failure", Duration::days(9)).await; + let failed_id_hex = forced_failure.id.simple().to_string().to_ascii_uppercase(); + sqlx::query(&format!( + "CREATE TRIGGER fail_selected_workspace_delete + BEFORE DELETE ON workspaces + WHEN hex(OLD.id) = '{failed_id_hex}' + BEGIN + SELECT RAISE(FAIL, 'forced deletion failure'); + END" + )) + .execute(&deployment.db().pool) + .await + .unwrap(); + + let results = submit_bulk_delete( + app, + vec![ + workspace_target(&forced_failure), + workspace_target(&after_failure), + ], + ) + .await; + assert_eq!( + result_for(&results, forced_failure.id)["outcome"]["status"], + "failed" + ); assert!( - Workspace::find_by_id(&deployment.db().pool, deletable.id) - .await + result_for(&results, forced_failure.id)["outcome"]["reason"] + .as_str() .unwrap() - .is_none() - ); - for untouched_id in [running.id, today.id, older.id] { - assert!( - Workspace::find_by_id(&deployment.db().pool, untouched_id) - .await - .unwrap() - .is_some(), - "workspace {untouched_id} must remain" - ); - } + .contains("forced deletion failure") + ); + assert_eq!( + result_for(&results, after_failure.id)["outcome"]["status"], + "deleted" + ); + assert_workspace_exists(&deployment, forced_failure.id).await; + assert_workspace_deleted(&deployment, after_failure.id).await; shutdown.cancel(); } diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx index db6448500a..20c3d5ac1a 100644 --- a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -9,6 +9,8 @@ import { XCircleIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; +import type { BulkDeleteTarget } from 'shared/types'; +import type { BulkDeleteArchivedWorkspaceDetails } from '../lib/bulkDeleteArchivedWorkspaces'; import { Button } from './Button'; import { Dialog, @@ -19,12 +21,6 @@ import { DialogTitle, } from './KeyboardDialog'; -export interface BulkDeleteDialogWorkspace { - id: string; - name: string; - repoCount?: number; -} - export interface BulkDeleteDialogBranchStatus { commitsAhead: number | null; } @@ -43,12 +39,17 @@ export interface BulkDeleteDialogItemResult { export interface BulkDeleteArchivedWorkspacesDialogProps { open: boolean; bucketLabel: string; - workspaces: BulkDeleteDialogWorkspace[]; + targets: BulkDeleteTarget[]; + detailsByWorkspaceId: Readonly< + Record + >; onOpenChange: (open: boolean) => void; inspectWorkspace: ( workspaceId: string ) => Promise; - onConfirm: () => Promise; + onConfirm: ( + targets: BulkDeleteTarget[] + ) => Promise; } interface InspectionSummary { @@ -62,27 +63,27 @@ interface InspectionSummary { const MAX_INSPECTION_CONCURRENCY = 8; async function inspectWorkspaces( - workspaces: BulkDeleteDialogWorkspace[], + targets: BulkDeleteTarget[], inspectWorkspace: BulkDeleteArchivedWorkspacesDialogProps['inspectWorkspace'] ) { const results: Array<{ - workspace: BulkDeleteDialogWorkspace; + target: BulkDeleteTarget; statuses: BulkDeleteDialogBranchStatus[]; - }> = new Array(workspaces.length); + }> = new Array(targets.length); let nextIndex = 0; const workers = Array.from( { - length: Math.min(MAX_INSPECTION_CONCURRENCY, workspaces.length), + length: Math.min(MAX_INSPECTION_CONCURRENCY, targets.length), }, async () => { - while (nextIndex < workspaces.length) { + while (nextIndex < targets.length) { const index = nextIndex; nextIndex += 1; - const workspace = workspaces[index]; + const target = targets[index]; results[index] = { - workspace, - statuses: await inspectWorkspace(workspace.id), + target, + statuses: await inspectWorkspace(target.workspace_id), }; } } @@ -116,7 +117,8 @@ function OutcomeIcon({ export function BulkDeleteArchivedWorkspacesDialog({ open, bucketLabel, - workspaces, + targets, + detailsByWorkspaceId, onOpenChange, inspectWorkspace, onConfirm, @@ -133,14 +135,20 @@ export function BulkDeleteArchivedWorkspacesDialog({ ); const initialRepoCount = useMemo(() => { - if (workspaces.some((workspace) => workspace.repoCount === undefined)) { + if ( + targets.some( + (target) => + detailsByWorkspaceId[target.workspace_id]?.repoCount === undefined + ) + ) { return null; } - return workspaces.reduce( - (count, workspace) => count + (workspace.repoCount ?? 0), + return targets.reduce( + (count, target) => + count + (detailsByWorkspaceId[target.workspace_id]?.repoCount ?? 0), 0 ); - }, [workspaces]); + }, [detailsByWorkspaceId, targets]); useEffect(() => { if (!open) return; @@ -152,7 +160,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ setInspectionError(null); setIsInspecting(true); - void inspectWorkspaces(workspaces, inspectWorkspace) + void inspectWorkspaces(targets, inspectWorkspace) .then((workspaceStatuses) => { if (canceled) return; @@ -203,7 +211,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ return () => { canceled = true; }; - }, [inspectWorkspace, inspectionAttempt, open, t, workspaces]); + }, [inspectWorkspace, inspectionAttempt, open, t, targets]); const resultCounts = useMemo(() => { if (!results) return null; @@ -222,7 +230,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ setIsDeleting(true); setOperationError(null); try { - setResults(await onConfirm()); + setResults(await onConfirm(targets)); } catch (error) { setOperationError( error instanceof Error @@ -296,7 +304,10 @@ export function BulkDeleteArchivedWorkspacesDialog({
- {result.workspaceName ?? result.workspaceId} + {result.workspaceName ?? + detailsByWorkspaceId[result.workspaceId] + ?.workspaceName ?? + result.workspaceId} {result.outcome.status} @@ -328,7 +339,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ })}
- {workspaces.length} + {targets.length}
@@ -419,9 +430,13 @@ export function BulkDeleteArchivedWorkspacesDialog({ })}

    - {workspaces.map((workspace) => ( -
  • - {workspace.name} + {targets.map((target) => ( +
  • + {detailsByWorkspaceId[target.workspace_id]?.workspaceName ?? + target.workspace_id}
  • ))}
@@ -460,7 +475,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ defaultValue: 'Removing…', }) : t('kanban.workspaceSidebar.bulkDeleteConfirm', { - count: workspaces.length, + count: targets.length, defaultValue: 'Remove {{count}} workspaces', })} diff --git a/packages/ui/src/components/WorkspacesSidebar.tsx b/packages/ui/src/components/WorkspacesSidebar.tsx index 4c381ce82d..114bc45229 100644 --- a/packages/ui/src/components/WorkspacesSidebar.tsx +++ b/packages/ui/src/components/WorkspacesSidebar.tsx @@ -10,12 +10,16 @@ import { TrashIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; -import type { ArchiveBucket } from 'shared/types'; +import type { ArchiveBucket, BulkDeleteTarget } from 'shared/types'; import { cn } from '../lib/cn'; import { ARCHIVE_BUCKET_ORDER, archiveBucketForTimestamp, } from '../lib/archiveBuckets'; +import { + buildArchivedBucketState, + type BulkDeleteArchivedWorkspaceDetails, +} from '../lib/bulkDeleteArchivedWorkspaces'; import { InputField } from './InputField'; import { WorkspaceSummary } from './WorkspaceSummary'; export type WorkspacesSidebarHostStatus = 'online' | 'offline' | 'unpaired'; @@ -97,6 +101,8 @@ export interface WorkspacesSidebarProps { onAddWorkspace?: () => void; searchQuery: string; onSearchChange: (value: string) => void; + /** Whether a PR filter currently narrows the archived rows. */ + hasActivePrFilter?: boolean; /** Whether we're in create mode */ isCreateMode?: boolean; /** Title extracted from draft message (only shown when isCreateMode and non-empty) */ @@ -125,9 +131,9 @@ export interface WorkspacesSidebarProps { inspectArchivedWorkspace?: ( workspaceId: string ) => Promise; - /** Permanently removes the server-resolved contents of an archive bucket. */ + /** Permanently removes the reviewed targets from an archive bucket. */ onBulkDeleteArchivedBucket?: ( - bucket: ArchiveBucket + targets: BulkDeleteTarget[] ) => Promise; /** Persist keys for collapsible sections */ persistKeys?: WorkspacesSidebarPersistKeys; @@ -228,6 +234,7 @@ export function WorkspacesSidebar({ onAddWorkspace, searchQuery, onSearchChange, + hasActivePrFilter = false, isCreateMode = false, draftTitle, onSelectCreate, @@ -300,11 +307,23 @@ export function WorkspacesSidebar({ }, [archivedWorkspaces]); const [bulkDeleteTarget, setBulkDeleteTarget] = useState<{ - bucket: ArchiveBucket; label: string; - workspaces: WorkspacesSidebarWorkspace[]; + targets: BulkDeleteTarget[]; + detailsByWorkspaceId: Readonly< + Record + >; } | null>(null); + const bulkArchiveActionsDisabled = + searchQuery.length > 0 || hasActivePrFilter; + const bulkArchiveActionsDisabledLabel = t( + 'common:workspaces.archiveBucketActionsFiltered', + { + defaultValue: + 'Clear active filters to remove archived workspaces in bulk', + } + ); + const getArchiveBucketLabel = (bucket: ArchiveBucket) => t(ARCHIVE_BUCKET_TRANSLATION_KEYS[bucket], { defaultValue: ARCHIVE_BUCKET_LABELS[bucket], @@ -417,6 +436,12 @@ export function WorkspacesSidebar({ ) : ( archivedBuckets.map((group) => { const label = getArchiveBucketLabel(group.bucket); + // Targets intentionally come from the complete bucket group; + // visibility only controls which rows the sidebar renders. + const bucketState = buildArchivedBucketState( + group.workspaces, + visibleArchivedWorkspaceIds + ); return ( - + bulkArchiveActionsDisabled ? ( + - - - - setBulkDeleteTarget({ - bucket: group.bucket, - label, - workspaces: [...group.workspaces], - }) - } - > - - {t('common:workspaces.removeArchiveBucket', { - defaultValue: 'Remove all in this bucket', - })} - - - + + ) : ( + + + + + + + setBulkDeleteTarget({ + label, + targets: bucketState.targets, + detailsByWorkspaceId: + bucketState.detailsByWorkspaceId, + }) + } + > + + {t('common:workspaces.removeArchiveBucket', { + defaultValue: 'Remove all in this bucket', + })} + + + + ) ) : null } >
- {group.workspaces - .filter( - (workspace) => - !visibleArchivedWorkspaceIds || - visibleArchivedWorkspaceIds.has(workspace.id) - ) - .map((workspace) => ( - onSelectWorkspace(workspace.id)} - /> - ))} + {bucketState.visibleWorkspaces.map((workspace) => ( + onSelectWorkspace(workspace.id)} + /> + ))}
); @@ -674,11 +713,10 @@ export function WorkspacesSidebar({ - onBulkDeleteArchivedBucket(bulkDeleteTarget.bucket) - } + onConfirm={onBulkDeleteArchivedBucket} onOpenChange={(open) => { if (!open) setBulkDeleteTarget(null); }} diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts new file mode 100644 index 0000000000..07bcf2c8cf --- /dev/null +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { archiveBucketForTimestamp } from './archiveBuckets'; +import { buildArchivedBucketState } from './bulkDeleteArchivedWorkspaces'; + +describe('buildArchivedBucketState', () => { + it('builds targets from the full bucket despite search and pagination visibility', () => { + const fullBucket = Array.from({ length: 55 }, (_, index) => ({ + id: `workspace-${index}`, + name: `Workspace ${index}`, + archivedAt: index === 54 ? null : '2026-06-01T12:00:00Z', + })); + const paginatedIds = new Set( + fullBucket.slice(0, 50).map((workspace) => workspace.id) + ); + const searchMatchedIds = new Set( + fullBucket + .filter((workspace) => workspace.name.endsWith('1')) + .map((workspace) => workspace.id) + .filter((workspaceId) => paginatedIds.has(workspaceId)) + ); + + const { targets, detailsByWorkspaceId, visibleWorkspaces } = + buildArchivedBucketState(fullBucket, searchMatchedIds); + + expect(visibleWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'workspace-1', + 'workspace-11', + 'workspace-21', + 'workspace-31', + 'workspace-41', + ]); + expect(targets).toHaveLength(fullBucket.length); + expect(targets.map((target) => target.workspace_id)).toEqual( + fullBucket.map((workspace) => workspace.id) + ); + expect(targets.at(-1)?.archived_at).toBeNull(); + expect(detailsByWorkspaceId['workspace-54'].workspaceName).toBe( + 'Workspace 54' + ); + expect( + archiveBucketForTimestamp( + fullBucket[54].archivedAt, + Date.UTC(2026, 6, 22, 12) + ) + ).toBe('older_than_thirty_days'); + }); +}); diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts new file mode 100644 index 0000000000..85007c27e0 --- /dev/null +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts @@ -0,0 +1,47 @@ +import type { BulkDeleteTarget } from 'shared/types'; + +export interface ArchivedWorkspaceBulkDeleteSource { + id: string; + name: string; + archivedAt: string | null; + repoCount?: number; +} + +export interface BulkDeleteArchivedWorkspaceDetails { + workspaceName: string; + repoCount?: number; +} + +export function buildArchivedBucketState< + TWorkspace extends ArchivedWorkspaceBulkDeleteSource, +>( + groupWorkspaces: readonly TWorkspace[], + visibleWorkspaceIds?: ReadonlySet +): { + targets: BulkDeleteTarget[]; + detailsByWorkspaceId: Readonly< + Record + >; + visibleWorkspaces: TWorkspace[]; +} { + return { + targets: groupWorkspaces.map((workspace) => ({ + workspace_id: workspace.id, + archived_at: workspace.archivedAt, + })), + detailsByWorkspaceId: Object.fromEntries( + groupWorkspaces.map((workspace) => [ + workspace.id, + { + workspaceName: workspace.name, + repoCount: workspace.repoCount, + }, + ]) + ), + visibleWorkspaces: visibleWorkspaceIds + ? groupWorkspaces.filter((workspace) => + visibleWorkspaceIds.has(workspace.id) + ) + : [...groupWorkspaces], + }; +} diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index 86a7dd407d..bcd974f7a5 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -221,6 +221,7 @@ "archiveBucketFifteenToThirtyDays": "15–30 days", "archiveBucketOlderThanThirtyDays": "Older than 30 days", "archiveBucketActions": "Actions for {{bucket}}", + "archiveBucketActionsFiltered": "Clear active filters to remove archived workspaces in bulk", "removeArchiveBucket": "Remove all in this bucket", "archivedRecentlyCount": "Archived recently ({{count}})", "restoreArchivedWorkspace": "Restore {{workspace}}", @@ -511,7 +512,7 @@ "bulkDeleteTitle": "Remove archived workspaces", "bulkDeleteResultsTitle": "Removal results", "bulkDeleteResultsDescription": "Each workspace is reported separately so skipped or failed removals are visible.", - "bulkDeleteDescription": "This permanently removes every workspace currently in “{{bucket}}”. This action cannot be undone.", + "bulkDeleteDescription": "This permanently removes the workspaces listed below from “{{bucket}}” if their archive state is unchanged. This action cannot be undone.", "bulkDeleteResultSummary": "{{deleted}} removed, {{skipped}} skipped, {{failed}} failed", "bulkDeleteWorkspaces": "Workspaces", "bulkDeleteBranches": "Branches deleted", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 628ebe7997..ca48dfb069 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -207,6 +207,7 @@ "archiveBucketFifteenToThirtyDays": "15–30 días", "archiveBucketOlderThanThirtyDays": "Más de 30 días", "archiveBucketActions": "Acciones para {{bucket}}", + "archiveBucketActionsFiltered": "Borra los filtros activos para eliminar espacios de trabajo archivados en bloque", "removeArchiveBucket": "Eliminar todos los de este grupo", "archivedRecentlyCount": "Archivados recientemente ({{count}})", "restoreArchivedWorkspace": "Restaurar {{workspace}}", @@ -497,7 +498,7 @@ "bulkDeleteTitle": "Eliminar espacios de trabajo archivados", "bulkDeleteResultsTitle": "Resultados de eliminación", "bulkDeleteResultsDescription": "Cada espacio de trabajo se informa por separado para mostrar las eliminaciones omitidas o fallidas.", - "bulkDeleteDescription": "Esto elimina permanentemente todos los espacios de trabajo que estén en «{{bucket}}». Esta acción no se puede deshacer.", + "bulkDeleteDescription": "Esto elimina permanentemente los espacios de trabajo enumerados a continuación de «{{bucket}}» si su estado de archivado no ha cambiado. Esta acción no se puede deshacer.", "bulkDeleteResultSummary": "{{deleted}} eliminados, {{skipped}} omitidos, {{failed}} fallidos", "bulkDeleteWorkspaces": "Espacios de trabajo", "bulkDeleteBranches": "Ramas que se eliminarán", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 7c24cfe3f0..48d890579c 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -207,6 +207,7 @@ "archiveBucketFifteenToThirtyDays": "15–30 jours", "archiveBucketOlderThanThirtyDays": "Plus de 30 jours", "archiveBucketActions": "Actions pour {{bucket}}", + "archiveBucketActionsFiltered": "Effacez les filtres actifs pour supprimer des espaces de travail archivés en bloc", "removeArchiveBucket": "Tout supprimer dans ce groupe", "archivedRecentlyCount": "Archivés récemment ({{count}})", "restoreArchivedWorkspace": "Restaurer {{workspace}}", @@ -497,7 +498,7 @@ "bulkDeleteTitle": "Supprimer les espaces de travail archivés", "bulkDeleteResultsTitle": "Résultats de la suppression", "bulkDeleteResultsDescription": "Chaque espace de travail est signalé séparément afin que les suppressions ignorées ou échouées restent visibles.", - "bulkDeleteDescription": "Cette action supprime définitivement tous les espaces de travail actuellement dans « {{bucket}} ». Elle est irréversible.", + "bulkDeleteDescription": "Cette action supprime définitivement les espaces de travail listés ci-dessous de « {{bucket}} » si leur état d’archivage n’a pas changé. Elle est irréversible.", "bulkDeleteResultSummary": "{{deleted}} supprimés, {{skipped}} ignorés, {{failed}} échoués", "bulkDeleteWorkspaces": "Espaces de travail", "bulkDeleteBranches": "Branches supprimées", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index e3d2c6a514..ca0083b3e2 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -207,6 +207,7 @@ "archiveBucketFifteenToThirtyDays": "15~30日前", "archiveBucketOlderThanThirtyDays": "30日より前", "archiveBucketActions": "{{bucket}}の操作", + "archiveBucketActionsFiltered": "アクティブなフィルターを解除して、アーカイブ済みワークスペースを一括削除します", "removeArchiveBucket": "このグループをすべて削除", "archivedRecentlyCount": "最近アーカイブ ({{count}})", "restoreArchivedWorkspace": "{{workspace}}を復元", @@ -497,7 +498,7 @@ "bulkDeleteTitle": "アーカイブ済みワークスペースを削除", "bulkDeleteResultsTitle": "削除結果", "bulkDeleteResultsDescription": "スキップまたは失敗した削除を確認できるよう、ワークスペースごとに結果を表示します。", - "bulkDeleteDescription": "「{{bucket}}」にあるすべてのワークスペースを完全に削除します。この操作は元に戻せません。", + "bulkDeleteDescription": "アーカイブ状態が変更されていない場合、以下の「{{bucket}}」のワークスペースを完全に削除します。この操作は元に戻せません。", "bulkDeleteResultSummary": "削除 {{deleted}}件、スキップ {{skipped}}件、失敗 {{failed}}件", "bulkDeleteWorkspaces": "ワークスペース", "bulkDeleteBranches": "削除されるブランチ", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index f2d0b0f39f..d338a1555a 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -207,6 +207,7 @@ "archiveBucketFifteenToThirtyDays": "15~30일", "archiveBucketOlderThanThirtyDays": "30일 초과", "archiveBucketActions": "{{bucket}} 작업", + "archiveBucketActionsFiltered": "보관된 워크스페이스를 일괄 삭제하려면 활성 필터를 해제하세요", "removeArchiveBucket": "이 구간 모두 삭제", "archivedRecentlyCount": "최근 보관됨 ({{count}})", "restoreArchivedWorkspace": "{{workspace}} 복원", @@ -497,7 +498,7 @@ "bulkDeleteTitle": "보관된 워크스페이스 삭제", "bulkDeleteResultsTitle": "삭제 결과", "bulkDeleteResultsDescription": "건너뛰거나 실패한 삭제를 확인할 수 있도록 워크스페이스별 결과를 표시합니다.", - "bulkDeleteDescription": "현재 “{{bucket}}”에 있는 모든 워크스페이스를 영구 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "bulkDeleteDescription": "보관 상태가 변경되지 않은 경우 아래 나열된 “{{bucket}}” 워크스페이스를 영구 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "bulkDeleteResultSummary": "{{deleted}}개 삭제, {{skipped}}개 건너뜀, {{failed}}개 실패", "bulkDeleteWorkspaces": "워크스페이스", "bulkDeleteBranches": "삭제할 브랜치", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index c6ea6bcb33..aeb2f87f5c 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -207,6 +207,7 @@ "archiveBucketFifteenToThirtyDays": "15–30 天", "archiveBucketOlderThanThirtyDays": "30 天以上", "archiveBucketActions": "{{bucket}}操作", + "archiveBucketActionsFiltered": "清除当前筛选条件后才能批量删除已归档工作区", "removeArchiveBucket": "删除此分组中的全部工作区", "archivedRecentlyCount": "最近归档 ({{count}})", "restoreArchivedWorkspace": "恢复 {{workspace}}", @@ -497,7 +498,7 @@ "bulkDeleteTitle": "删除已归档工作区", "bulkDeleteResultsTitle": "删除结果", "bulkDeleteResultsDescription": "逐个显示工作区结果,以便查看跳过或失败的删除。", - "bulkDeleteDescription": "这会永久删除当前位于“{{bucket}}”中的所有工作区。此操作无法撤销。", + "bulkDeleteDescription": "如果归档状态未发生变化,这会永久删除下方列出的“{{bucket}}”工作区。此操作无法撤销。", "bulkDeleteResultSummary": "已删除 {{deleted}} 个,已跳过 {{skipped}} 个,失败 {{failed}} 个", "bulkDeleteWorkspaces": "工作区", "bulkDeleteBranches": "将删除的分支", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index ac987b8a87..15e6538d77 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -207,6 +207,7 @@ "archiveBucketFifteenToThirtyDays": "15–30 天", "archiveBucketOlderThanThirtyDays": "30 天以上", "archiveBucketActions": "{{bucket}}操作", + "archiveBucketActionsFiltered": "清除目前的篩選條件後才能批次刪除已封存的工作區", "removeArchiveBucket": "刪除此分組中的全部工作區", "archivedRecentlyCount": "最近封存 ({{count}})", "restoreArchivedWorkspace": "還原 {{workspace}}", @@ -497,7 +498,7 @@ "bulkDeleteTitle": "刪除已封存工作區", "bulkDeleteResultsTitle": "刪除結果", "bulkDeleteResultsDescription": "逐一顯示工作區結果,以便查看略過或失敗的刪除。", - "bulkDeleteDescription": "這會永久刪除目前位於「{{bucket}}」中的所有工作區。此操作無法復原。", + "bulkDeleteDescription": "如果封存狀態未變更,這會永久刪除下方列出的「{{bucket}}」工作區。此操作無法復原。", "bulkDeleteResultSummary": "已刪除 {{deleted}} 個,已略過 {{skipped}} 個,失敗 {{failed}} 個", "bulkDeleteWorkspaces": "工作區", "bulkDeleteBranches": "將刪除的分支", diff --git a/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx b/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx index c0827a6ed5..4df3ee2fee 100644 --- a/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx +++ b/packages/web-core/src/pages/workspaces/WorkspacesSidebarContainer.tsx @@ -5,7 +5,7 @@ import { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext'; import { useScratch } from '@/shared/hooks/useScratch'; import { ScratchType, - type ArchiveBucket, + type BulkDeleteTarget, type DraftWorkspaceData, } from 'shared/types'; import { splitMessageToTitleDescription } from '@/shared/lib/string'; @@ -511,9 +511,11 @@ export function WorkspacesSidebarContainer({ ); const bulkDeleteArchivedBucket = useCallback( - async (bucket: ArchiveBucket): Promise => { + async ( + targets: BulkDeleteTarget[] + ): Promise => { const response = await workspacesApi.bulkDeleteArchived({ - bucket, + targets, delete_branches: true, }); return response.results.map((result) => ({ @@ -615,6 +617,7 @@ export function WorkspacesSidebarContainer({ onSelectWorkspace={handleSelectWorkspace} searchQuery={searchQuery} onSearchChange={setSearchQuery} + hasActivePrFilter={hasActiveFilters} onAddWorkspace={handleAddWorkspace} isCreateMode={isCreateMode} draftTitle={persistedDraftTitle} diff --git a/shared/types.ts b/shared/types.ts index 23ac334d66..42aa977081 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -420,7 +420,9 @@ export type ChangeTargetBranchRequest = { repo_id: string, new_target_branch: st export type ChangeTargetBranchResponse = { repo_id: string, new_target_branch: string, status: [number, number], }; -export type BulkDeleteArchivedWorkspacesRequest = { bucket: ArchiveBucket, delete_branches: boolean, }; +export type BulkDeleteTarget = { workspace_id: string, archived_at: string | null, }; + +export type BulkDeleteArchivedWorkspacesRequest = { targets: Array, delete_branches: boolean, }; export type BulkDeleteItemOutcome = { "status": "deleted" } | { "status": "skipped", reason: string, } | { "status": "failed", reason: string, }; From dd86cf04d98ddda87c983cb687ed8378e93b7706 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 21:52:47 +0000 Subject: [PATCH 16/25] fix(ui): restore archived workspace status badges --- packages/ui/src/components/WorkspacesSidebar.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/ui/src/components/WorkspacesSidebar.tsx b/packages/ui/src/components/WorkspacesSidebar.tsx index 114bc45229..8a0d027796 100644 --- a/packages/ui/src/components/WorkspacesSidebar.tsx +++ b/packages/ui/src/components/WorkspacesSidebar.tsx @@ -513,7 +513,20 @@ export function WorkspacesSidebar({ key={workspace.id} name={workspace.name} workspaceId={workspace.id} + filesChanged={workspace.filesChanged} + linesAdded={workspace.linesAdded} + linesRemoved={workspace.linesRemoved} isActive={selectedWorkspaceId === workspace.id} + isRunning={workspace.isRunning} + isPinned={workspace.isPinned} + hasPendingApproval={workspace.hasPendingApproval} + hasRunningDevServer={workspace.hasRunningDevServer} + hasUnseenActivity={workspace.hasUnseenActivity} + latestProcessCompletedAt={ + workspace.latestProcessCompletedAt + } + latestProcessStatus={workspace.latestProcessStatus} + prStatus={workspace.prStatus} onOpenWorkspaceActions={handleOpenWorkspaceActions} onClick={() => onSelectWorkspace(workspace.id)} /> From 4812ce04b4b3b50fdcfd3d3d15964108accb7016 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 21:53:42 +0000 Subject: [PATCH 17/25] fix(ui): surface archived workspace restore failures --- .../ui/src/components/WorkspacesSidebar.tsx | 30 ++++++++++++++++++- .../web-core/src/i18n/locales/en/common.json | 1 + .../web-core/src/i18n/locales/es/common.json | 1 + .../web-core/src/i18n/locales/fr/common.json | 1 + .../web-core/src/i18n/locales/ja/common.json | 1 + .../web-core/src/i18n/locales/ko/common.json | 1 + .../src/i18n/locales/zh-Hans/common.json | 1 + .../src/i18n/locales/zh-Hant/common.json | 1 + 8 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/WorkspacesSidebar.tsx b/packages/ui/src/components/WorkspacesSidebar.tsx index 8a0d027796..e8c545e827 100644 --- a/packages/ui/src/components/WorkspacesSidebar.tsx +++ b/packages/ui/src/components/WorkspacesSidebar.tsx @@ -313,6 +313,7 @@ export function WorkspacesSidebar({ Record >; } | null>(null); + const [restoreError, setRestoreError] = useState(null); const bulkArchiveActionsDisabled = searchQuery.length > 0 || hasActivePrFilter; @@ -329,6 +330,25 @@ export function WorkspacesSidebar({ defaultValue: ARCHIVE_BUCKET_LABELS[bucket], }); + const handleRestoreWorkspace = useCallback( + async (workspace: WorkspacesSidebarWorkspace) => { + if (!onRestoreWorkspace) return; + + setRestoreError(null); + try { + await onRestoreWorkspace(workspace.id); + } catch { + setRestoreError( + t('common:workspaces.restoreArchivedWorkspaceFailed', { + workspace: workspace.name, + defaultValue: 'Could not restore {{workspace}}. Try again.', + }) + ); + } + }, + [onRestoreWorkspace, t] + ); + const headerActions: SectionAction[] = [ { icon: StackIcon, @@ -687,9 +707,17 @@ export function WorkspacesSidebar({ defaultValue: 'Restore {{workspace}}', } )} - onClick={() => void onRestoreWorkspace(workspace.id)} + onClick={() => void handleRestoreWorkspace(workspace)} /> ))} + {restoreError && ( +

+ {restoreError} +

+ )}
diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index bcd974f7a5..43400e8507 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -225,6 +225,7 @@ "removeArchiveBucket": "Remove all in this bucket", "archivedRecentlyCount": "Archived recently ({{count}})", "restoreArchivedWorkspace": "Restore {{workspace}}", + "restoreArchivedWorkspaceFailed": "Could not restore {{workspace}}. Try again.", "rename": { "title": "Rename Workspace", "description": "Enter a new name for this workspace.", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index ca48dfb069..24b948c4ca 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -211,6 +211,7 @@ "removeArchiveBucket": "Eliminar todos los de este grupo", "archivedRecentlyCount": "Archivados recientemente ({{count}})", "restoreArchivedWorkspace": "Restaurar {{workspace}}", + "restoreArchivedWorkspaceFailed": "No se pudo restaurar {{workspace}}. Inténtalo de nuevo.", "rename": { "title": "Renombrar espacio de trabajo", "description": "Ingresa un nuevo nombre para este espacio de trabajo.", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 48d890579c..51cd03b1e9 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -211,6 +211,7 @@ "removeArchiveBucket": "Tout supprimer dans ce groupe", "archivedRecentlyCount": "Archivés récemment ({{count}})", "restoreArchivedWorkspace": "Restaurer {{workspace}}", + "restoreArchivedWorkspaceFailed": "Impossible de restaurer {{workspace}}. Réessayez.", "rename": { "title": "Renommer l'espace de travail", "description": "Saisissez un nouveau nom pour cet espace de travail.", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index ca0083b3e2..024655af4a 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -211,6 +211,7 @@ "removeArchiveBucket": "このグループをすべて削除", "archivedRecentlyCount": "最近アーカイブ ({{count}})", "restoreArchivedWorkspace": "{{workspace}}を復元", + "restoreArchivedWorkspaceFailed": "{{workspace}}を復元できませんでした。もう一度お試しください。", "rename": { "title": "ワークスペースの名前を変更", "description": "このワークスペースの新しい名前を入力してください。", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index d338a1555a..75df75da5a 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -211,6 +211,7 @@ "removeArchiveBucket": "이 구간 모두 삭제", "archivedRecentlyCount": "최근 보관됨 ({{count}})", "restoreArchivedWorkspace": "{{workspace}} 복원", + "restoreArchivedWorkspaceFailed": "{{workspace}}을(를) 복원하지 못했습니다. 다시 시도하세요.", "rename": { "title": "워크스페이스 이름 변경", "description": "이 워크스페이스의 새 이름을 입력하세요.", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index aeb2f87f5c..131f6d1221 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -211,6 +211,7 @@ "removeArchiveBucket": "删除此分组中的全部工作区", "archivedRecentlyCount": "最近归档 ({{count}})", "restoreArchivedWorkspace": "恢复 {{workspace}}", + "restoreArchivedWorkspaceFailed": "无法恢复 {{workspace}}。请重试。", "rename": { "title": "重命名工作区", "description": "输入此工作区的新名称。", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 15e6538d77..f302449105 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -211,6 +211,7 @@ "removeArchiveBucket": "刪除此分組中的全部工作區", "archivedRecentlyCount": "最近封存 ({{count}})", "restoreArchivedWorkspace": "還原 {{workspace}}", + "restoreArchivedWorkspaceFailed": "無法還原 {{workspace}}。請再試一次。", "rename": { "title": "重新命名工作區", "description": "輸入此工作區的新名稱。", From c38124308182c5dc904e16e530a68d9f8882ebd1 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 21:55:32 +0000 Subject: [PATCH 18/25] fix(ui): preserve partial branch inspection results --- .../BulkDeleteArchivedWorkspacesDialog.tsx | 65 ++++++++----------- .../lib/bulkDeleteArchivedWorkspaces.test.ts | 42 +++++++++++- .../src/lib/bulkDeleteArchivedWorkspaces.ts | 48 ++++++++++++++ .../web-core/src/i18n/locales/en/common.json | 1 + .../web-core/src/i18n/locales/es/common.json | 1 + .../web-core/src/i18n/locales/fr/common.json | 1 + .../web-core/src/i18n/locales/ja/common.json | 1 + .../web-core/src/i18n/locales/ko/common.json | 1 + .../src/i18n/locales/zh-Hans/common.json | 1 + .../src/i18n/locales/zh-Hant/common.json | 1 + 10 files changed, 124 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx index 20c3d5ac1a..a858428809 100644 --- a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -10,7 +10,10 @@ import { } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import type { BulkDeleteTarget } from 'shared/types'; -import type { BulkDeleteArchivedWorkspaceDetails } from '../lib/bulkDeleteArchivedWorkspaces'; +import { + inspectArchivedWorkspaceTargets, + type BulkDeleteArchivedWorkspaceDetails, +} from '../lib/bulkDeleteArchivedWorkspaces'; import { Button } from './Button'; import { Dialog, @@ -58,39 +61,7 @@ interface InspectionSummary { unmergedBranchCount: number; unmergedWorkspaceCount: number; unknownComparisonCount: number; -} - -const MAX_INSPECTION_CONCURRENCY = 8; - -async function inspectWorkspaces( - targets: BulkDeleteTarget[], - inspectWorkspace: BulkDeleteArchivedWorkspacesDialogProps['inspectWorkspace'] -) { - const results: Array<{ - target: BulkDeleteTarget; - statuses: BulkDeleteDialogBranchStatus[]; - }> = new Array(targets.length); - let nextIndex = 0; - - const workers = Array.from( - { - length: Math.min(MAX_INSPECTION_CONCURRENCY, targets.length), - }, - async () => { - while (nextIndex < targets.length) { - const index = nextIndex; - nextIndex += 1; - const target = targets[index]; - results[index] = { - target, - statuses: await inspectWorkspace(target.workspace_id), - }; - } - } - ); - - await Promise.all(workers); - return results; + inspectionFailureCount: number; } function OutcomeIcon({ @@ -160,7 +131,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ setInspectionError(null); setIsInspecting(true); - void inspectWorkspaces(targets, inspectWorkspace) + void inspectArchivedWorkspaceTargets(targets, inspectWorkspace) .then((workspaceStatuses) => { if (canceled) return; @@ -185,6 +156,9 @@ export function BulkDeleteArchivedWorkspacesDialog({ .length, 0 ); + const inspectionFailureCount = workspaceStatuses.filter( + (item) => item.inspectionFailed + ).length; setInspection({ branchCount, @@ -192,6 +166,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ unmergedBranchCount, unmergedWorkspaceCount, unknownComparisonCount, + inspectionFailureCount, }); }) .catch((error: unknown) => { @@ -244,8 +219,8 @@ export function BulkDeleteArchivedWorkspacesDialog({ } }; - const branchCount = inspection?.branchCount ?? initialRepoCount; - const worktreeCount = inspection?.worktreeCount ?? initialRepoCount; + const branchCount = initialRepoCount ?? inspection?.branchCount; + const worktreeCount = initialRepoCount ?? inspection?.worktreeCount; return ( )} + {inspection && inspection.inspectionFailureCount > 0 && ( + <> +
+ {t( + 'kanban.workspaceSidebar.bulkDeleteInspectionFailures', + { + defaultValue: + 'Workspaces whose unmerged status is unknown', + } + )} +
+
+ {inspection.inspectionFailureCount} +
+ + )} diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts index 07bcf2c8cf..273b069d90 100644 --- a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import { archiveBucketForTimestamp } from './archiveBuckets'; -import { buildArchivedBucketState } from './bulkDeleteArchivedWorkspaces'; +import { + buildArchivedBucketState, + inspectArchivedWorkspaceTargets, +} from './bulkDeleteArchivedWorkspaces'; describe('buildArchivedBucketState', () => { it('builds targets from the full bucket despite search and pagination visibility', () => { @@ -45,3 +48,40 @@ describe('buildArchivedBucketState', () => { ).toBe('older_than_thirty_days'); }); }); + +describe('inspectArchivedWorkspaceTargets', () => { + it('preserves successful results when one workspace inspection fails', async () => { + const targets = ['first', 'failing', 'last'].map((workspaceId) => ({ + workspace_id: workspaceId, + archived_at: '2026-06-01T12:00:00Z', + })); + + const results = await inspectArchivedWorkspaceTargets( + targets, + async (workspaceId) => { + if (workspaceId === 'failing') { + throw new Error('transient inspection failure'); + } + return [{ commitsAhead: workspaceId === 'first' ? 1 : 0 }]; + } + ); + + expect(results).toEqual([ + { + target: targets[0], + statuses: [{ commitsAhead: 1 }], + inspectionFailed: false, + }, + { + target: targets[1], + statuses: [], + inspectionFailed: true, + }, + { + target: targets[2], + statuses: [{ commitsAhead: 0 }], + inspectionFailed: false, + }, + ]); + }); +}); diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts index 85007c27e0..7b5befa6d5 100644 --- a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts @@ -1,5 +1,7 @@ import type { BulkDeleteTarget } from 'shared/types'; +const MAX_INSPECTION_CONCURRENCY = 8; + export interface ArchivedWorkspaceBulkDeleteSource { id: string; name: string; @@ -45,3 +47,49 @@ export function buildArchivedBucketState< : [...groupWorkspaces], }; } + +export interface ArchivedWorkspaceInspectionResult { + target: BulkDeleteTarget; + statuses: TStatus[]; + inspectionFailed: boolean; +} + +export async function inspectArchivedWorkspaceTargets( + targets: readonly BulkDeleteTarget[], + inspectWorkspace: (workspaceId: string) => Promise +): Promise[]> { + const results: ArchivedWorkspaceInspectionResult[] = new Array( + targets.length + ); + let nextIndex = 0; + + const workers = Array.from( + { + length: Math.min(MAX_INSPECTION_CONCURRENCY, targets.length), + }, + async () => { + while (nextIndex < targets.length) { + const index = nextIndex; + nextIndex += 1; + const target = targets[index]; + + try { + results[index] = { + target, + statuses: await inspectWorkspace(target.workspace_id), + inspectionFailed: false, + }; + } catch { + results[index] = { + target, + statuses: [], + inspectionFailed: true, + }; + } + } + } + ); + + await Promise.all(workers); + return results; +} diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index 43400e8507..2a6da650fe 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -523,6 +523,7 @@ "bulkDeleteInspecting": "Inspecting…", "bulkDeleteUnmergedWorkspaces": "across {{count}} workspace(s)", "bulkDeleteInspectionFailed": "Could not inspect every branch.", + "bulkDeleteInspectionFailures": "Workspaces whose unmerged status is unknown", "bulkDeleteRequestFailed": "The bulk removal request failed.", "bulkDeleteRetry": "Retry", "bulkDeleteTargets": "Workspaces that will be removed", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 24b948c4ca..a33e3e3f4f 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -509,6 +509,7 @@ "bulkDeleteInspecting": "Inspeccionando…", "bulkDeleteUnmergedWorkspaces": "en {{count}} espacio(s) de trabajo", "bulkDeleteInspectionFailed": "No se pudieron inspeccionar todas las ramas.", + "bulkDeleteInspectionFailures": "Espacios de trabajo cuyo estado de commits sin fusionar se desconoce", "bulkDeleteRequestFailed": "La solicitud de eliminación masiva falló.", "bulkDeleteRetry": "Reintentar", "bulkDeleteTargets": "Espacios de trabajo que se eliminarán", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 51cd03b1e9..a022e0d99b 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -509,6 +509,7 @@ "bulkDeleteInspecting": "Analyse…", "bulkDeleteUnmergedWorkspaces": "dans {{count}} espace(s) de travail", "bulkDeleteInspectionFailed": "Impossible d’analyser toutes les branches.", + "bulkDeleteInspectionFailures": "Espaces de travail dont l’état de fusion est inconnu", "bulkDeleteRequestFailed": "La demande de suppression groupée a échoué.", "bulkDeleteRetry": "Réessayer", "bulkDeleteTargets": "Espaces de travail qui seront supprimés", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index 024655af4a..41298a6c38 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -509,6 +509,7 @@ "bulkDeleteInspecting": "確認中…", "bulkDeleteUnmergedWorkspaces": "{{count}}件のワークスペース", "bulkDeleteInspectionFailed": "すべてのブランチを確認できませんでした。", + "bulkDeleteInspectionFailures": "未マージ状態を確認できないワークスペース", "bulkDeleteRequestFailed": "一括削除リクエストに失敗しました。", "bulkDeleteRetry": "再試行", "bulkDeleteTargets": "削除されるワークスペース", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index 75df75da5a..dd62bf340c 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -509,6 +509,7 @@ "bulkDeleteInspecting": "검사 중…", "bulkDeleteUnmergedWorkspaces": "{{count}}개 워크스페이스", "bulkDeleteInspectionFailed": "모든 브랜치를 검사하지 못했습니다.", + "bulkDeleteInspectionFailures": "병합되지 않은 상태를 알 수 없는 워크스페이스", "bulkDeleteRequestFailed": "대량 삭제 요청에 실패했습니다.", "bulkDeleteRetry": "다시 시도", "bulkDeleteTargets": "삭제될 워크스페이스", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index 131f6d1221..6eb9d21b0a 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -509,6 +509,7 @@ "bulkDeleteInspecting": "正在检查…", "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 个工作区", "bulkDeleteInspectionFailed": "无法检查所有分支。", + "bulkDeleteInspectionFailures": "无法确定未合并状态的工作区", "bulkDeleteRequestFailed": "批量删除请求失败。", "bulkDeleteRetry": "重试", "bulkDeleteTargets": "将删除的工作区", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index f302449105..29f184c825 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -509,6 +509,7 @@ "bulkDeleteInspecting": "正在檢查…", "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 個工作區", "bulkDeleteInspectionFailed": "無法檢查所有分支。", + "bulkDeleteInspectionFailures": "無法確定未合併狀態的工作區", "bulkDeleteRequestFailed": "批次刪除請求失敗。", "bulkDeleteRetry": "重試", "bulkDeleteTargets": "將刪除的工作區", From dd4472cd6d4bdbcc6b3d7899414e374d96ccf542 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 21:57:45 +0000 Subject: [PATCH 19/25] fix(ui): avoid inspecting removed worktrees --- .../BulkDeleteArchivedWorkspacesDialog.tsx | 58 ++++++++++++++++++- .../ui/src/components/WorkspacesSidebar.tsx | 1 + .../lib/bulkDeleteArchivedWorkspaces.test.ts | 29 ++++++++++ .../src/lib/bulkDeleteArchivedWorkspaces.ts | 19 +++++- .../web-core/src/i18n/locales/en/common.json | 1 + .../web-core/src/i18n/locales/es/common.json | 1 + .../web-core/src/i18n/locales/fr/common.json | 1 + .../web-core/src/i18n/locales/ja/common.json | 1 + .../web-core/src/i18n/locales/ko/common.json | 1 + .../src/i18n/locales/zh-Hans/common.json | 1 + .../src/i18n/locales/zh-Hant/common.json | 1 + .../src/shared/hooks/useWorkspaces.ts | 2 + 12 files changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx index a858428809..37d6eba238 100644 --- a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -62,6 +62,7 @@ interface InspectionSummary { unmergedWorkspaceCount: number; unknownComparisonCount: number; inspectionFailureCount: number; + worktreeAlreadyRemovedCount: number; } function OutcomeIcon({ @@ -121,6 +122,25 @@ export function BulkDeleteArchivedWorkspacesDialog({ ); }, [detailsByWorkspaceId, targets]); + const initialWorktreeCount = useMemo(() => { + const targetsWithWorktrees = targets.filter( + (target) => !detailsByWorkspaceId[target.workspace_id]?.worktreeDeleted + ); + if ( + targetsWithWorktrees.some( + (target) => + detailsByWorkspaceId[target.workspace_id]?.repoCount === undefined + ) + ) { + return null; + } + return targetsWithWorktrees.reduce( + (count, target) => + count + (detailsByWorkspaceId[target.workspace_id]?.repoCount ?? 0), + 0 + ); + }, [detailsByWorkspaceId, targets]); + useEffect(() => { if (!open) return; @@ -131,7 +151,12 @@ export function BulkDeleteArchivedWorkspacesDialog({ setInspectionError(null); setIsInspecting(true); - void inspectArchivedWorkspaceTargets(targets, inspectWorkspace) + void inspectArchivedWorkspaceTargets( + targets, + inspectWorkspace, + (workspaceId) => + detailsByWorkspaceId[workspaceId]?.worktreeDeleted === true + ) .then((workspaceStatuses) => { if (canceled) return; @@ -159,6 +184,9 @@ export function BulkDeleteArchivedWorkspacesDialog({ const inspectionFailureCount = workspaceStatuses.filter( (item) => item.inspectionFailed ).length; + const worktreeAlreadyRemovedCount = workspaceStatuses.filter( + (item) => item.worktreeAlreadyRemoved + ).length; setInspection({ branchCount, @@ -167,6 +195,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ unmergedWorkspaceCount, unknownComparisonCount, inspectionFailureCount, + worktreeAlreadyRemovedCount, }); }) .catch((error: unknown) => { @@ -186,7 +215,14 @@ export function BulkDeleteArchivedWorkspacesDialog({ return () => { canceled = true; }; - }, [inspectWorkspace, inspectionAttempt, open, t, targets]); + }, [ + detailsByWorkspaceId, + inspectWorkspace, + inspectionAttempt, + open, + t, + targets, + ]); const resultCounts = useMemo(() => { if (!results) return null; @@ -220,7 +256,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ }; const branchCount = initialRepoCount ?? inspection?.branchCount; - const worktreeCount = initialRepoCount ?? inspection?.worktreeCount; + const worktreeCount = initialWorktreeCount ?? inspection?.worktreeCount; return ( )} + {inspection && inspection.worktreeAlreadyRemovedCount > 0 && ( + <> +
+ {t( + 'kanban.workspaceSidebar.bulkDeleteWorktreeAlreadyRemoved', + { + defaultValue: + 'Unmerged status unknown, worktree already removed', + } + )} +
+
+ {inspection.worktreeAlreadyRemovedCount} +
+ + )} diff --git a/packages/ui/src/components/WorkspacesSidebar.tsx b/packages/ui/src/components/WorkspacesSidebar.tsx index e8c545e827..872a75226b 100644 --- a/packages/ui/src/components/WorkspacesSidebar.tsx +++ b/packages/ui/src/components/WorkspacesSidebar.tsx @@ -63,6 +63,7 @@ export interface WorkspacesSidebarWorkspace { id: string; name: string; archivedAt: string | null; + worktreeDeleted?: boolean; repoCount?: number; filesChanged?: number; linesAdded?: number; diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts index 273b069d90..000af00157 100644 --- a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts @@ -71,17 +71,46 @@ describe('inspectArchivedWorkspaceTargets', () => { target: targets[0], statuses: [{ commitsAhead: 1 }], inspectionFailed: false, + worktreeAlreadyRemoved: false, }, { target: targets[1], statuses: [], inspectionFailed: true, + worktreeAlreadyRemoved: false, }, { target: targets[2], statuses: [{ commitsAhead: 0 }], inspectionFailed: false, + worktreeAlreadyRemoved: false, }, ]); }); + + it('does not inspect a workspace whose worktree is already removed', async () => { + const targets = ['removed', 'existing'].map((workspaceId) => ({ + workspace_id: workspaceId, + archived_at: '2026-06-01T12:00:00Z', + })); + const inspectedWorkspaceIds: string[] = []; + + const results = await inspectArchivedWorkspaceTargets( + targets, + async (workspaceId) => { + inspectedWorkspaceIds.push(workspaceId); + return [{ commitsAhead: 0 }]; + }, + (workspaceId) => workspaceId === 'removed' + ); + + expect(inspectedWorkspaceIds).toEqual(['existing']); + expect(results[0]).toEqual({ + target: targets[0], + statuses: [], + inspectionFailed: false, + worktreeAlreadyRemoved: true, + }); + expect(results[1].worktreeAlreadyRemoved).toBe(false); + }); }); diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts index 7b5befa6d5..9b099a93bc 100644 --- a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts @@ -6,12 +6,14 @@ export interface ArchivedWorkspaceBulkDeleteSource { id: string; name: string; archivedAt: string | null; + worktreeDeleted?: boolean; repoCount?: number; } export interface BulkDeleteArchivedWorkspaceDetails { workspaceName: string; repoCount?: number; + worktreeDeleted: boolean; } export function buildArchivedBucketState< @@ -37,6 +39,7 @@ export function buildArchivedBucketState< { workspaceName: workspace.name, repoCount: workspace.repoCount, + worktreeDeleted: workspace.worktreeDeleted === true, }, ]) ), @@ -52,11 +55,13 @@ export interface ArchivedWorkspaceInspectionResult { target: BulkDeleteTarget; statuses: TStatus[]; inspectionFailed: boolean; + worktreeAlreadyRemoved: boolean; } export async function inspectArchivedWorkspaceTargets( targets: readonly BulkDeleteTarget[], - inspectWorkspace: (workspaceId: string) => Promise + inspectWorkspace: (workspaceId: string) => Promise, + isWorktreeAlreadyRemoved: (workspaceId: string) => boolean = () => false ): Promise[]> { const results: ArchivedWorkspaceInspectionResult[] = new Array( targets.length @@ -73,17 +78,29 @@ export async function inspectArchivedWorkspaceTargets( nextIndex += 1; const target = targets[index]; + if (isWorktreeAlreadyRemoved(target.workspace_id)) { + results[index] = { + target, + statuses: [], + inspectionFailed: false, + worktreeAlreadyRemoved: true, + }; + continue; + } + try { results[index] = { target, statuses: await inspectWorkspace(target.workspace_id), inspectionFailed: false, + worktreeAlreadyRemoved: false, }; } catch { results[index] = { target, statuses: [], inspectionFailed: true, + worktreeAlreadyRemoved: false, }; } } diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index 2a6da650fe..076455411b 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -524,6 +524,7 @@ "bulkDeleteUnmergedWorkspaces": "across {{count}} workspace(s)", "bulkDeleteInspectionFailed": "Could not inspect every branch.", "bulkDeleteInspectionFailures": "Workspaces whose unmerged status is unknown", + "bulkDeleteWorktreeAlreadyRemoved": "Unmerged status unknown, worktree already removed", "bulkDeleteRequestFailed": "The bulk removal request failed.", "bulkDeleteRetry": "Retry", "bulkDeleteTargets": "Workspaces that will be removed", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index a33e3e3f4f..0d7035f1d3 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -510,6 +510,7 @@ "bulkDeleteUnmergedWorkspaces": "en {{count}} espacio(s) de trabajo", "bulkDeleteInspectionFailed": "No se pudieron inspeccionar todas las ramas.", "bulkDeleteInspectionFailures": "Espacios de trabajo cuyo estado de commits sin fusionar se desconoce", + "bulkDeleteWorktreeAlreadyRemoved": "Estado sin fusionar desconocido; árbol de trabajo ya eliminado", "bulkDeleteRequestFailed": "La solicitud de eliminación masiva falló.", "bulkDeleteRetry": "Reintentar", "bulkDeleteTargets": "Espacios de trabajo que se eliminarán", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index a022e0d99b..882efda586 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -510,6 +510,7 @@ "bulkDeleteUnmergedWorkspaces": "dans {{count}} espace(s) de travail", "bulkDeleteInspectionFailed": "Impossible d’analyser toutes les branches.", "bulkDeleteInspectionFailures": "Espaces de travail dont l’état de fusion est inconnu", + "bulkDeleteWorktreeAlreadyRemoved": "État non fusionné inconnu, arborescence déjà supprimée", "bulkDeleteRequestFailed": "La demande de suppression groupée a échoué.", "bulkDeleteRetry": "Réessayer", "bulkDeleteTargets": "Espaces de travail qui seront supprimés", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index 41298a6c38..828a6444c2 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -510,6 +510,7 @@ "bulkDeleteUnmergedWorkspaces": "{{count}}件のワークスペース", "bulkDeleteInspectionFailed": "すべてのブランチを確認できませんでした。", "bulkDeleteInspectionFailures": "未マージ状態を確認できないワークスペース", + "bulkDeleteWorktreeAlreadyRemoved": "未マージ状態は不明(ワークツリーは削除済み)", "bulkDeleteRequestFailed": "一括削除リクエストに失敗しました。", "bulkDeleteRetry": "再試行", "bulkDeleteTargets": "削除されるワークスペース", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index dd62bf340c..96480bc84b 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -510,6 +510,7 @@ "bulkDeleteUnmergedWorkspaces": "{{count}}개 워크스페이스", "bulkDeleteInspectionFailed": "모든 브랜치를 검사하지 못했습니다.", "bulkDeleteInspectionFailures": "병합되지 않은 상태를 알 수 없는 워크스페이스", + "bulkDeleteWorktreeAlreadyRemoved": "병합되지 않은 상태를 알 수 없음, 워크트리는 이미 제거됨", "bulkDeleteRequestFailed": "대량 삭제 요청에 실패했습니다.", "bulkDeleteRetry": "다시 시도", "bulkDeleteTargets": "삭제될 워크스페이스", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index 6eb9d21b0a..cd9bc7f20b 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -510,6 +510,7 @@ "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 个工作区", "bulkDeleteInspectionFailed": "无法检查所有分支。", "bulkDeleteInspectionFailures": "无法确定未合并状态的工作区", + "bulkDeleteWorktreeAlreadyRemoved": "未合并状态未知,工作树已移除", "bulkDeleteRequestFailed": "批量删除请求失败。", "bulkDeleteRetry": "重试", "bulkDeleteTargets": "将删除的工作区", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 29f184c825..2cc06255a3 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -510,6 +510,7 @@ "bulkDeleteUnmergedWorkspaces": "涉及 {{count}} 個工作區", "bulkDeleteInspectionFailed": "無法檢查所有分支。", "bulkDeleteInspectionFailures": "無法確定未合併狀態的工作區", + "bulkDeleteWorktreeAlreadyRemoved": "未合併狀態不明,工作樹已移除", "bulkDeleteRequestFailed": "批次刪除請求失敗。", "bulkDeleteRetry": "重試", "bulkDeleteTargets": "將刪除的工作區", diff --git a/packages/web-core/src/shared/hooks/useWorkspaces.ts b/packages/web-core/src/shared/hooks/useWorkspaces.ts index 90c64414d0..0b19e17b1c 100644 --- a/packages/web-core/src/shared/hooks/useWorkspaces.ts +++ b/packages/web-core/src/shared/hooks/useWorkspaces.ts @@ -19,6 +19,7 @@ export interface SidebarWorkspace { createdAt: string; updatedAt: string; archivedAt: string | null; + worktreeDeleted: boolean; description: string; repoCount?: number; filesChanged?: number; @@ -69,6 +70,7 @@ function toSidebarWorkspace( createdAt: ws.created_at, updatedAt: ws.updated_at, archivedAt: ws.archived_at, + worktreeDeleted: ws.worktree_deleted, description: '', repoCount: summary?.repo_count, // Use real stats from summary if available From 1974b4242e59396355fbc30e532f406d01440944 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 21:59:45 +0000 Subject: [PATCH 20/25] fix(git): serialize shared repository cleanup --- .../src/workspace_manager.rs | 1 + .../worktree-manager/src/worktree_manager.rs | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/crates/workspace-manager/src/workspace_manager.rs b/crates/workspace-manager/src/workspace_manager.rs index 8a50372ac2..6545982116 100644 --- a/crates/workspace-manager/src/workspace_manager.rs +++ b/crates/workspace-manager/src/workspace_manager.rs @@ -262,6 +262,7 @@ impl WorkspaceManager { if delete_branches { let git_service = GitService::new(); for repo_path in repo_paths { + let _repo_guard = WorktreeManager::lock_repo_mutation(&repo_path).await; match git_service.delete_branch(&repo_path, &branch_name) { Ok(()) => { info!("Deleted branch '{}' from repo {:?}", branch_name, repo_path); diff --git a/crates/worktree-manager/src/worktree_manager.rs b/crates/worktree-manager/src/worktree_manager.rs index 4820212968..fe1104257b 100644 --- a/crates/worktree-manager/src/worktree_manager.rs +++ b/crates/worktree-manager/src/worktree_manager.rs @@ -16,6 +16,10 @@ use utils::{path::normalize_macos_private_alias, shell::resolve_executable_path} static WORKTREE_CREATION_LOCKS: LazyLock>>>> = LazyLock::new(|| Mutex::new(HashMap::new())); +// Serialize git metadata mutations across different worktrees that share a repo. +static REPO_MUTATION_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + #[derive(Debug, Clone)] pub struct WorktreeCleanup { pub worktree_path: PathBuf, @@ -52,6 +56,20 @@ pub enum WorktreeError { pub struct WorktreeManager; impl WorktreeManager { + pub async fn lock_repo_mutation(repo_path: &Path) -> tokio::sync::OwnedMutexGuard<()> { + let normalized_path = normalize_macos_private_alias(repo_path); + let canonical_path = dunce::canonicalize(&normalized_path).unwrap_or(normalized_path); + let lock = { + let mut locks = REPO_MUTATION_LOCKS.lock().unwrap(); + locks + .entry(canonical_path) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + + lock.lock_owned().await + } + pub fn set_workspace_dir_override(path: PathBuf) { let _ = WORKSPACE_DIR_OVERRIDE.set(path); } @@ -423,6 +441,7 @@ impl WorktreeManager { }; if let Some(repo_path) = resolved_repo_path { + let _repo_guard = Self::lock_repo_mutation(&repo_path).await; Self::comprehensive_worktree_cleanup_async(&repo_path, &worktree.worktree_path).await?; } else { // Can't determine repo path, just clean up the worktree directory @@ -589,3 +608,32 @@ async fn create_worktree_when_repo_path_is_a_worktree() { .await .unwrap(); } + +#[tokio::test] +async fn repo_mutation_lock_uses_the_canonical_repo_path() { + use std::time::Duration; + + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().join("repo"); + let nested_path = repo_path.join("nested"); + std::fs::create_dir_all(&nested_path).unwrap(); + + let first_guard = WorktreeManager::lock_repo_mutation(&repo_path).await; + let aliased_repo_path = nested_path.join(".."); + let waiter = + tokio::spawn(async move { WorktreeManager::lock_repo_mutation(&aliased_repo_path).await }); + + tokio::task::yield_now().await; + assert!( + !waiter.is_finished(), + "canonical aliases must contend on the same repository lock" + ); + + drop(first_guard); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("repo lock waiter should proceed after release") + .expect("repo lock waiter task should complete"); +} From 3dd41822bdb6916b8faf28db87f874d30950c748 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 22:01:09 +0000 Subject: [PATCH 21/25] test(db): exercise archived timestamp backfill --- crates/db/src/lib.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 2477cbdd28..bc5b090e36 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -148,3 +148,67 @@ impl DBService { Ok(pool) } } + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + use uuid::Uuid; + + #[tokio::test] + async fn archived_at_migration_backfills_preexisting_archived_rows() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query( + "CREATE TABLE workspaces ( + id BLOB PRIMARY KEY, + updated_at TEXT NOT NULL, + archived INTEGER NOT NULL + )", + ) + .execute(&pool) + .await + .unwrap(); + + let archived_id = Uuid::new_v4(); + let active_id = Uuid::new_v4(); + let archived_updated_at = "2026-07-20 12:34:56.789"; + sqlx::query("INSERT INTO workspaces (id, updated_at, archived) VALUES (?, ?, TRUE)") + .bind(archived_id) + .bind(archived_updated_at) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO workspaces (id, updated_at, archived) VALUES (?, ?, FALSE)") + .bind(active_id) + .bind("2026-07-21 01:02:03.456") + .execute(&pool) + .await + .unwrap(); + + sqlx::raw_sql(include_str!( + "../migrations/20260721103000_add_workspace_archived_at.sql" + )) + .execute(&pool) + .await + .unwrap(); + + let archived_at: Option = + sqlx::query_scalar("SELECT archived_at FROM workspaces WHERE id = ?") + .bind(archived_id) + .fetch_one(&pool) + .await + .unwrap(); + let active_archived_at: Option = + sqlx::query_scalar("SELECT archived_at FROM workspaces WHERE id = ?") + .bind(active_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(archived_at.as_deref(), Some(archived_updated_at)); + assert_eq!(active_archived_at, None); + } +} From e1c0211514028ba16b801548df42a54a5afd34eb Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 22:10:08 +0000 Subject: [PATCH 22/25] docs(workspaces): clarify reviewed-target deletion contract --- crates/server/src/routes/workspaces/core.rs | 2 +- .../ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/server/src/routes/workspaces/core.rs b/crates/server/src/routes/workspaces/core.rs index 337e7b1b4b..3d125f97ff 100644 --- a/crates/server/src/routes/workspaces/core.rs +++ b/crates/server/src/routes/workspaces/core.rs @@ -204,7 +204,7 @@ pub async fn bulk_delete_archived_workspaces( continue; } - // Keep the archived guard here: the shared delete path must still delete active workspaces. + // Do not make the shared DELETE conditional on archived: single-delete must remove active workspaces. let outcome = match delete_workspace_core( &deployment, fresh_workspace, diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx index 37d6eba238..648b345ac2 100644 --- a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -289,7 +289,7 @@ export function BulkDeleteArchivedWorkspacesDialog({ : t('kanban.workspaceSidebar.bulkDeleteDescription', { bucket: bucketLabel, defaultValue: - 'This permanently removes every workspace currently in “{{bucket}}”. This action cannot be undone.', + 'This permanently removes the workspaces listed below from “{{bucket}}” if their archive state is unchanged. This action cannot be undone.', })} From 4c1f6e3c3a8cc530852eb74798d8e93e15fc173e Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 22:36:19 +0000 Subject: [PATCH 23/25] test(ui): cover archived repository count sums --- .../BulkDeleteArchivedWorkspacesDialog.tsx | 20 +++------ .../lib/bulkDeleteArchivedWorkspaces.test.ts | 43 +++++++++++++++++++ .../src/lib/bulkDeleteArchivedWorkspaces.ts | 22 ++++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx index 648b345ac2..024db6526d 100644 --- a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'; import type { BulkDeleteTarget } from 'shared/types'; import { inspectArchivedWorkspaceTargets, + sumRepoCounts, type BulkDeleteArchivedWorkspaceDetails, } from '../lib/bulkDeleteArchivedWorkspaces'; import { Button } from './Button'; @@ -106,21 +107,10 @@ export function BulkDeleteArchivedWorkspacesDialog({ null ); - const initialRepoCount = useMemo(() => { - if ( - targets.some( - (target) => - detailsByWorkspaceId[target.workspace_id]?.repoCount === undefined - ) - ) { - return null; - } - return targets.reduce( - (count, target) => - count + (detailsByWorkspaceId[target.workspace_id]?.repoCount ?? 0), - 0 - ); - }, [detailsByWorkspaceId, targets]); + const initialRepoCount = useMemo( + () => sumRepoCounts(targets, detailsByWorkspaceId), + [detailsByWorkspaceId, targets] + ); const initialWorktreeCount = useMemo(() => { const targetsWithWorktrees = targets.filter( diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts index 000af00157..762ff578a5 100644 --- a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.test.ts @@ -3,8 +3,51 @@ import { archiveBucketForTimestamp } from './archiveBuckets'; import { buildArchivedBucketState, inspectArchivedWorkspaceTargets, + sumRepoCounts, } from './bulkDeleteArchivedWorkspaces'; +describe('sumRepoCounts', () => { + const targets = ['one-repo', 'two-repos', 'three-repos'].map( + (workspaceId) => ({ + workspace_id: workspaceId, + archived_at: '2026-06-01T12:00:00Z', + }) + ); + const detailsByWorkspaceId = { + 'one-repo': { + workspaceName: 'One repo', + repoCount: 1, + worktreeDeleted: false, + }, + 'two-repos': { + workspaceName: 'Two repos', + repoCount: 2, + worktreeDeleted: false, + }, + 'three-repos': { + workspaceName: 'Three repos', + repoCount: 3, + worktreeDeleted: false, + }, + }; + + it('sums mixed repository counts across the targeted workspaces', () => { + expect(sumRepoCounts(targets, detailsByWorkspaceId)).toBe(6); + }); + + it('returns null when any targeted workspace has an unknown repository count', () => { + expect( + sumRepoCounts(targets, { + ...detailsByWorkspaceId, + 'two-repos': { + ...detailsByWorkspaceId['two-repos'], + repoCount: undefined, + }, + }) + ).toBeNull(); + }); +}); + describe('buildArchivedBucketState', () => { it('builds targets from the full bucket despite search and pagination visibility', () => { const fullBucket = Array.from({ length: 55 }, (_, index) => ({ diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts index 9b099a93bc..07cffd98a4 100644 --- a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts @@ -16,6 +16,28 @@ export interface BulkDeleteArchivedWorkspaceDetails { worktreeDeleted: boolean; } +export function sumRepoCounts( + targets: readonly BulkDeleteTarget[], + detailsByWorkspaceId: Readonly< + Record + > +): number | null { + if ( + targets.some( + (target) => + detailsByWorkspaceId[target.workspace_id]?.repoCount === undefined + ) + ) { + return null; + } + + return targets.reduce( + (count, target) => + count + (detailsByWorkspaceId[target.workspace_id]?.repoCount ?? 0), + 0 + ); +} + export function buildArchivedBucketState< TWorkspace extends ArchivedWorkspaceBulkDeleteSource, >( From 28863688c16aea5f3d2d93b890c3e38dc5895772 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 22:37:12 +0000 Subject: [PATCH 24/25] test(frontend): add root test command --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 272102b839..6c6245cab7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format": "pnpm run backend:format && pnpm run web-core:format && pnpm run local-web:format && pnpm run remote-web:format && pnpm run ui:format", "check": "pnpm run local-web:legacy-path-guard && pnpm run local-web:check && pnpm run remote-web:check && pnpm run web-core:check && pnpm run ui:check && pnpm run backend:check", "dev": "export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export PREVIEW_PROXY_PORT=$(node scripts/setup-dev-environment.js preview_proxy) && export VK_ALLOWED_ORIGINS=\"http://localhost:${FRONTEND_PORT}\" && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && concurrently \"pnpm run backend:dev:watch\" \"pnpm run local-web:dev\"", + "test": "vitest run", "test:npm": "./test-npm-package.sh", "local-web:lint": "pnpm --filter @vibe/local-web run lint", "local-web:legacy-path-guard": "./scripts/check-legacy-frontend-paths.sh", From 72ce2fb58c789caaf664fe8b316ecbafa57877db Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Thu, 23 Jul 2026 12:12:26 +0000 Subject: [PATCH 25/25] fix(ui): hide incomplete archive deletion totals --- .../BulkDeleteArchivedWorkspacesDialog.tsx | 8 ++- .../lib/bulkDeleteArchivedWorkspaces.test.ts | 63 +++++++++++++++++++ .../src/lib/bulkDeleteArchivedWorkspaces.ts | 33 ++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx index 024db6526d..306dbd2a2a 100644 --- a/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx +++ b/packages/ui/src/components/BulkDeleteArchivedWorkspacesDialog.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'; import type { BulkDeleteTarget } from 'shared/types'; import { inspectArchivedWorkspaceTargets, + resolveDialogTotals, sumRepoCounts, type BulkDeleteArchivedWorkspaceDetails, } from '../lib/bulkDeleteArchivedWorkspaces'; @@ -245,8 +246,11 @@ export function BulkDeleteArchivedWorkspacesDialog({ } }; - const branchCount = initialRepoCount ?? inspection?.branchCount; - const worktreeCount = initialWorktreeCount ?? inspection?.worktreeCount; + const { branchCount, worktreeCount } = resolveDialogTotals({ + initialRepoCount, + initialWorktreeCount, + inspection, + }); return ( { }); }); +describe('resolveDialogTotals', () => { + const completeInspection = { + branchCount: 6, + worktreeCount: 5, + inspectionFailureCount: 0, + worktreeAlreadyRemovedCount: 0, + }; + + it('uses inspection totals when initial totals are unavailable and inspection is complete', () => { + expect( + resolveDialogTotals({ + initialRepoCount: null, + initialWorktreeCount: null, + inspection: completeInspection, + }) + ).toEqual({ + branchCount: 6, + worktreeCount: 5, + }); + }); + + it.each([ + ['an inspection failure', 1, 0], + ['a removed-worktree skip', 0, 1], + ])( + 'returns unknown fallback totals after %s', + (_reason, inspectionFailureCount, worktreeAlreadyRemovedCount) => { + expect( + resolveDialogTotals({ + initialRepoCount: null, + initialWorktreeCount: null, + inspection: { + ...completeInspection, + inspectionFailureCount, + worktreeAlreadyRemovedCount, + }, + }) + ).toEqual({ + branchCount: null, + worktreeCount: null, + }); + } + ); + + it('prefers initial totals regardless of incomplete inspection state', () => { + expect( + resolveDialogTotals({ + initialRepoCount: 8, + initialWorktreeCount: 7, + inspection: { + ...completeInspection, + inspectionFailureCount: 1, + worktreeAlreadyRemovedCount: 1, + }, + }) + ).toEqual({ + branchCount: 8, + worktreeCount: 7, + }); + }); +}); + describe('buildArchivedBucketState', () => { it('builds targets from the full bucket despite search and pagination visibility', () => { const fullBucket = Array.from({ length: 55 }, (_, index) => ({ diff --git a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts index 07cffd98a4..b235a397ac 100644 --- a/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts +++ b/packages/ui/src/lib/bulkDeleteArchivedWorkspaces.ts @@ -38,6 +38,39 @@ export function sumRepoCounts( ); } +export interface BulkDeleteArchivedWorkspaceInspectionTotals { + branchCount: number; + worktreeCount: number; + inspectionFailureCount: number; + worktreeAlreadyRemovedCount: number; +} + +export function resolveDialogTotals({ + initialRepoCount, + initialWorktreeCount, + inspection, +}: { + initialRepoCount: number | null; + initialWorktreeCount: number | null; + inspection: BulkDeleteArchivedWorkspaceInspectionTotals | null; +}): { + branchCount: number | null; + worktreeCount: number | null; +} { + const completeInspection = + inspection !== null && + inspection.inspectionFailureCount === 0 && + inspection.worktreeAlreadyRemovedCount === 0; + + return { + branchCount: + initialRepoCount ?? (completeInspection ? inspection.branchCount : null), + worktreeCount: + initialWorktreeCount ?? + (completeInspection ? inspection.worktreeCount : null), + }; +} + export function buildArchivedBucketState< TWorkspace extends ArchivedWorkspaceBulkDeleteSource, >(