From d9638ff242eb947f06154256971b1104359cf9dc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 14 Aug 2026 15:58:14 +1000 Subject: [PATCH 1/7] feat(actions): pin any number of actions to the card header with custom icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo's actions surfaced exactly one "main" button in the branch and repo card headers: the first `run`-type action by sort order, decided in the frontend and stored nowhere. This replaces that implicit single slot with an explicit pinned set — any number of actions, of any type, each rendered as its own icon button. `repo_actions` gains `pinned` and `icon` (a kebab-case Lucide name, NULL meaning "the default icon for this action type"). Migration 0025 pins each context's current implicit main action, tie-breaking deterministically because `sort_order` has never been unique, so no existing database loses its run button. Detection keeps detecting just the one run action and pins it only when the context had no run action at all — gating on that rather than "has nothing pinned" means deliberately unpinning survives a re-detect. `PrimaryRunActionButton` becomes `PinnedActionButton`, taking the action as a prop instead of deriving the one primary from the runner: each pinned run action tracks its own building/serving phase and endpoint pill, and pinned non-run actions spin then report their outcome. Running pills and the Actions submenu now exclude every pinned action rather than just the primary — the submenu's exclusion generalizes from the run group to all of them. Icon rendering and the picker live behind a lazily imported chunk of Lucide's full ~1,750-icon map. It has to import `@lucide/svelte/icons/index` rather than the `@lucide/svelte` barrel: the barrel is statically imported by the diff-viewer package, so a dynamic import of it can't split out and the whole set lands in the main bundle (+620 kB, measured). Stored kebab names convert back to Lucide's PascalCase exports; the inverse is lossy, so candidate spellings are round-tripped through the conversion rendering uses and the first survivor wins — which reproduces lucide.dev's own spelling for all but three of the icons and guarantees every produced name resolves. Verified with `just ci` equivalents: cargo fmt/clippy, 742 Rust tests, svelte- check, and 684 vitest tests all pass; `vite build` confirms the icon chunk splits (main bundle 1,717 kB, below the 1,734 kB baseline). Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/actions/commands.rs | 77 ++++++- apps/staged/src-tauri/src/lib.rs | 11 +- apps/staged/src-tauri/src/store/actions.rs | 27 ++- .../src-tauri/src/store/migration_tests.rs | 151 +++++++++++-- .../migrations/0028-pinned-actions/up.sql | 26 +++ apps/staged/src-tauri/src/store/models.rs | 17 ++ apps/staged/src-tauri/src/store/tests.rs | 46 ++++ apps/staged/src-tauri/src/web_server.rs | 10 +- apps/staged/src/lib/commands.ts | 16 +- .../lib/features/actions/ActionIcon.svelte | 41 ++++ .../features/actions/ActionsSubmenu.svelte | 4 +- .../lib/features/actions/IconPicker.svelte | 201 ++++++++++++++++++ ...utton.svelte => PinnedActionButton.svelte} | 86 ++++---- .../actions/RunningActionPills.svelte | 2 +- .../lib/features/actions/actionGroups.test.ts | 78 +++++++ .../src/lib/features/actions/actionGroups.ts | 39 ++-- .../lib/features/actions/actionMenu.test.ts | 85 ++++++++ .../src/lib/features/actions/actionMenu.ts | 56 ++--- .../features/actions/actionRunner.svelte.ts | 39 ++-- .../lib/features/actions/actionRunner.test.ts | 26 +++ .../src/lib/features/actions/actions.ts | 4 + .../lib/features/actions/iconNames.test.ts | 69 ++++++ .../src/lib/features/actions/iconNames.ts | 95 +++++++++ .../src/lib/features/actions/lucideIcons.ts | 83 ++++++++ .../branches/BranchCardActionsBar.svelte | 20 +- .../src/lib/features/projects/RepoCard.svelte | 8 +- .../settings/ActionsSettingsPanel.svelte | 84 +++++--- 27 files changed, 1208 insertions(+), 193 deletions(-) create mode 100644 apps/staged/src-tauri/src/store/migrations/0028-pinned-actions/up.sql create mode 100644 apps/staged/src/lib/features/actions/ActionIcon.svelte create mode 100644 apps/staged/src/lib/features/actions/IconPicker.svelte rename apps/staged/src/lib/features/actions/{PrimaryRunActionButton.svelte => PinnedActionButton.svelte} (78%) create mode 100644 apps/staged/src/lib/features/actions/actionGroups.test.ts create mode 100644 apps/staged/src/lib/features/actions/actionMenu.test.ts create mode 100644 apps/staged/src/lib/features/actions/iconNames.test.ts create mode 100644 apps/staged/src/lib/features/actions/iconNames.ts create mode 100644 apps/staged/src/lib/features/actions/lucideIcons.ts diff --git a/apps/staged/src-tauri/src/actions/commands.rs b/apps/staged/src-tauri/src/actions/commands.rs index a70aaf9c..cfc40d98 100644 --- a/apps/staged/src-tauri/src/actions/commands.rs +++ b/apps/staged/src-tauri/src/actions/commands.rs @@ -141,6 +141,14 @@ fn resolve_branch_repo_context( /// Persist detected suggestions into an action context, skipping commands the /// context already has and continuing its sort order. /// +/// A context that had no run action at all before this call gets the first +/// run-type suggestion pinned, so a freshly detected repo arrives with the play +/// button in its card header that detection has always implied. The gate is +/// "had no run actions", not "has nothing pinned": a user who deliberately +/// unpins their run action would otherwise have it pinned right back by the +/// next re-detect. Contexts that predate pinning are covered by the 0026 +/// migration instead. +/// /// Persistence belongs inside the detection window: every surface treats the /// `detecting: false` half of the `repo-actions-detection` broadcast as "this /// context's action list is final", so a caller that detects here and persists @@ -165,12 +173,17 @@ pub(crate) fn persist_suggested_actions( .max() .unwrap_or(-1) + 1; + let mut pin_next_run_action = !existing_actions + .iter() + .any(|a| a.action_type == ActionType::Run); for suggestion in suggestions { if existing_commands.contains(&suggestion.command) { continue; } existing_commands.insert(suggestion.command.clone()); + let pinned = pin_next_run_action && suggestion.action_type == ActionType::Run; + pin_next_run_action &= !pinned; let action = crate::store::RepoAction::new( context_id.to_string(), suggestion.name, @@ -178,7 +191,8 @@ pub(crate) fn persist_suggested_actions( suggestion.action_type, next_sort_order, ) - .with_auto_commit(suggestion.auto_commit); + .with_auto_commit(suggestion.auto_commit) + .with_pinned(pinned); store .create_repo_action(&action) .map_err(|e| format!("Failed to create detected action: {e}"))?; @@ -1615,6 +1629,67 @@ mod tests { .collect::>(), vec![("Dev", 0), ("Test", 1), ("Build", 2)] ); + + // The context started with no run action, so the first run suggestion + // is what the card header ends up showing — and only that one. + assert_eq!( + actions + .iter() + .filter(|a| a.pinned) + .map(|a| a.name.as_str()) + .collect::>(), + vec!["Dev"] + ); + // Detection never picks an icon; NULL means the action type's default. + assert!(actions.iter().all(|a| a.icon.is_none())); + } + + #[test] + fn persist_suggested_actions_pins_only_the_first_run_action_of_a_fresh_context() { + let store = Store::in_memory().unwrap(); + let context = store + .get_or_create_action_context("block/builderbot", Some("apps/staged")) + .unwrap(); + + persist_suggested_actions( + &store, + &context.id, + vec![ + suggestion("Build", "just build", ActionType::Build), + suggestion("Dev", "just dev", ActionType::Run), + suggestion("Storybook", "just storybook", ActionType::Run), + ], + ) + .unwrap(); + + let pinned = |store: &Store| -> Vec { + store + .list_repo_actions(&context.id) + .unwrap() + .into_iter() + .filter(|a| a.pinned) + .map(|a| a.name) + .collect() + }; + assert_eq!(pinned(&store), vec!["Dev".to_string()]); + + // Unpinning is a deliberate choice, so a later re-detect that turns up + // another run action leaves the header empty rather than re-pinning. + let dev = store + .list_repo_actions(&context.id) + .unwrap() + .into_iter() + .find(|a| a.name == "Dev") + .unwrap(); + store.update_repo_action(&dev.with_pinned(false)).unwrap(); + + persist_suggested_actions( + &store, + &context.id, + vec![suggestion("Preview", "just preview", ActionType::Run)], + ) + .unwrap(); + assert!(pinned(&store).is_empty()); } /// A context mid-detection: the flag claimed by `pid`, nothing marked yet. diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 3a48186c..9c70f3ad 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1595,6 +1595,7 @@ fn list_project_actions( } #[tauri::command(rename_all = "camelCase")] +#[allow(clippy::too_many_arguments)] fn update_project_action( store: tauri::State<'_, Mutex>>>, action_id: String, @@ -1603,6 +1604,8 @@ fn update_project_action( action_type: String, sort_order: i32, auto_commit: bool, + pinned: bool, + icon: Option, ) -> Result<(), String> { let store = get_store(&store)?; let action = store @@ -1620,6 +1623,8 @@ fn update_project_action( sort_order, auto_commit, run_detection_mode: action.run_detection_mode, + pinned, + icon, created_at: action.created_at, updated_at: store::now_timestamp(), }; @@ -1684,6 +1689,8 @@ fn create_repo_action( action_type: String, sort_order: i32, auto_commit: bool, + pinned: bool, + icon: Option, ) -> Result { let store = get_store(&store)?; let context = store @@ -1692,7 +1699,9 @@ fn create_repo_action( let parsed_type = builderbot_actions::ActionType::parse(&action_type) .ok_or_else(|| format!("Invalid action type: {action_type}"))?; let action = store::models::RepoAction::new(context.id, name, command, parsed_type, sort_order) - .with_auto_commit(auto_commit); + .with_auto_commit(auto_commit) + .with_pinned(pinned) + .with_icon(icon); store .create_repo_action(&action) .map_err(|e| e.to_string())?; diff --git a/apps/staged/src-tauri/src/store/actions.rs b/apps/staged/src-tauri/src/store/actions.rs index 8d681224..8d8ad23c 100644 --- a/apps/staged/src-tauri/src/store/actions.rs +++ b/apps/staged/src-tauri/src/store/actions.rs @@ -240,8 +240,8 @@ impl Store { .transpose() .map_err(|e| StoreError(format!("Failed to serialize run_detection_mode: {e}")))?; conn.execute( - "INSERT INTO repo_actions (id, context_id, name, command, action_type, sort_order, auto_commit, run_detection_mode, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + "INSERT INTO repo_actions (id, context_id, name, command, action_type, sort_order, auto_commit, run_detection_mode, pinned, icon, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ action.id, action.context_id, @@ -251,6 +251,8 @@ impl Store { action.sort_order, action.auto_commit as i32, run_detection_mode_json, + action.pinned as i32, + action.icon, action.created_at, action.updated_at, ], @@ -261,7 +263,7 @@ impl Store { pub fn get_repo_action(&self, id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, context_id, name, command, action_type, sort_order, auto_commit, run_detection_mode, created_at, updated_at + "SELECT id, context_id, name, command, action_type, sort_order, auto_commit, run_detection_mode, pinned, icon, created_at, updated_at FROM repo_actions WHERE id = ?1", params![id], Self::row_to_repo_action, @@ -273,7 +275,7 @@ impl Store { pub fn list_repo_actions(&self, context_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, context_id, name, command, action_type, sort_order, auto_commit, run_detection_mode, created_at, updated_at + "SELECT id, context_id, name, command, action_type, sort_order, auto_commit, run_detection_mode, pinned, icon, created_at, updated_at FROM repo_actions WHERE context_id = ?1 ORDER BY sort_order ASC", )?; let rows = stmt.query_map(params![context_id], Self::row_to_repo_action)?; @@ -289,15 +291,15 @@ impl Store { pub fn list_all_repo_actions(&self) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT a.id, a.context_id, a.name, a.command, a.action_type, a.sort_order, a.auto_commit, a.run_detection_mode, a.created_at, a.updated_at, c.github_repo, c.subpath + "SELECT a.id, a.context_id, a.name, a.command, a.action_type, a.sort_order, a.auto_commit, a.run_detection_mode, a.pinned, a.icon, a.created_at, a.updated_at, c.github_repo, c.subpath FROM repo_actions a JOIN action_contexts c ON a.context_id = c.id ORDER BY c.id ASC, a.sort_order ASC", )?; let rows = stmt.query_map([], |row| { let action = Self::row_to_repo_action(row)?; - let github_repo: String = row.get(10)?; - let subpath: Option = row.get(11)?; + let github_repo: String = row.get(12)?; + let subpath: Option = row.get(13)?; Ok((github_repo, subpath, action)) })?; @@ -332,7 +334,7 @@ impl Store { .transpose() .map_err(|e| StoreError(format!("Failed to serialize run_detection_mode: {e}")))?; conn.execute( - "UPDATE repo_actions SET name = ?1, command = ?2, action_type = ?3, sort_order = ?4, auto_commit = ?5, run_detection_mode = ?6, updated_at = ?7 WHERE id = ?8", + "UPDATE repo_actions SET name = ?1, command = ?2, action_type = ?3, sort_order = ?4, auto_commit = ?5, run_detection_mode = ?6, pinned = ?7, icon = ?8, updated_at = ?9 WHERE id = ?10", params![ action.name, action.command, @@ -340,6 +342,8 @@ impl Store { action.sort_order, action.auto_commit as i32, run_detection_mode_json, + action.pinned as i32, + action.icon, now_timestamp(), action.id, ], @@ -404,6 +408,7 @@ impl Store { let run_detection_mode: Option = run_detection_mode_str .as_deref() .and_then(|s| serde_json::from_str(s).ok()); + let pinned: i32 = row.get(8)?; Ok(RepoAction { id: row.get(0)?, context_id: row.get(1)?, @@ -413,8 +418,10 @@ impl Store { sort_order: row.get(5)?, auto_commit: auto_commit != 0, run_detection_mode, - created_at: row.get(8)?, - updated_at: row.get(9)?, + pinned: pinned != 0, + icon: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) } } diff --git a/apps/staged/src-tauri/src/store/migration_tests.rs b/apps/staged/src-tauri/src/store/migration_tests.rs index 5378229b..9b98af0e 100644 --- a/apps/staged/src-tauri/src/store/migration_tests.rs +++ b/apps/staged/src-tauri/src/store/migration_tests.rs @@ -145,7 +145,7 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { ) .unwrap(); - assert_eq!(version, 27); + assert_eq!(version, 28); assert_eq!(app_version, super::APP_VERSION); assert!(table_exists(&conn, "projects")); assert!(table_exists(&conn, "project_notes")); @@ -164,6 +164,8 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { assert!(column_exists(&conn, "sessions", "completion_effects_at")); assert!(column_exists(&conn, "notes", "parent_project_note_id")); assert!(!column_exists(&conn, "reviews", "is_auto")); + assert!(column_exists(&conn, "repo_actions", "pinned")); + assert!(column_exists(&conn, "repo_actions", "icon")); let trigger_count: i64 = conn .query_row( @@ -227,12 +229,19 @@ fn test_store_repairs_github_comment_tracking_user_version() { github_comment_type TEXT, github_comment_stale INTEGER NOT NULL DEFAULT 0 ); - -- Only the column the 0024 backfill reads; the rest of the real - -- table predates every migration below. + -- Only the columns the 0024 and 0028 backfills read; the rest of the + -- real tables predates every migration below. CREATE TABLE action_contexts ( id TEXT PRIMARY KEY, detecting_actions INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE repo_actions ( + id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); ", ) .unwrap(); @@ -245,7 +254,7 @@ fn test_store_repairs_github_comment_tracking_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 27); + assert_eq!(version, 28); assert!(column_exists(&conn, "sessions", "pipeline")); assert!(column_exists(&conn, "sessions", "acp_config_selection")); assert!(column_exists(&conn, "sessions", "acp_title")); @@ -302,12 +311,19 @@ fn test_store_repairs_pipeline_user_version() { PRIMARY KEY (github_repo, subpath) ); CREATE TABLE comments (id TEXT PRIMARY KEY); - -- Only the column the 0024 backfill reads; the rest of the real - -- table predates every migration below. + -- Only the columns the 0024 and 0028 backfills read; the rest of the + -- real tables predates every migration below. CREATE TABLE action_contexts ( id TEXT PRIMARY KEY, detecting_actions INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE repo_actions ( + id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); ", ) .unwrap(); @@ -320,7 +336,7 @@ fn test_store_repairs_pipeline_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 27); + assert_eq!(version, 28); assert!(column_exists(&conn, "comments", "github_comment_id")); assert!(column_exists(&conn, "comments", "github_comment_type")); assert!(column_exists(&conn, "comments", "github_comment_stale")); @@ -361,7 +377,7 @@ fn test_completion_effects_migration_backfills_finished_pipeline_sessions() { ('running-pipeline', 'running', '{}', 200), ('error-pipeline', 'error', '{}', 300), ('completed-ai', 'completed', NULL, 400); - -- Only the column the 0024 backfill reads. + -- Only the columns the 0024 and 0028 backfills read. CREATE TABLE action_contexts ( id TEXT PRIMARY KEY, detecting_actions INTEGER NOT NULL DEFAULT 0 @@ -373,6 +389,14 @@ fn test_completion_effects_migration_backfills_finished_pipeline_sessions() { id TEXT PRIMARY KEY, is_auto INTEGER NOT NULL DEFAULT 0 ); + -- Only the columns the 0028 backfill reads. + CREATE TABLE repo_actions ( + id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); ", ) .unwrap(); @@ -385,7 +409,7 @@ fn test_completion_effects_migration_backfills_finished_pipeline_sessions() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 27); + assert_eq!(version, 28); assert!(column_exists(&conn, "sessions", "completion_effects_at")); let marker = |id: &str| -> Option { @@ -429,6 +453,14 @@ fn test_auto_review_removal_migration_deletes_auto_reviews_and_drops_flag() { ('auto-review', 1); -- Only the table the 0027 note column add targets. CREATE TABLE notes (id TEXT PRIMARY KEY, session_id TEXT); + -- Only the columns the 0028 backfill reads. + CREATE TABLE repo_actions ( + id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); ", ) .unwrap(); @@ -441,7 +473,7 @@ fn test_auto_review_removal_migration_deletes_auto_reviews_and_drops_flag() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 27); + assert_eq!(version, 28); assert!(!column_exists(&conn, "reviews", "is_auto")); // Reviews the removed auto-review feature created in the background are @@ -484,6 +516,14 @@ fn test_detecting_pid_migration_clears_orphaned_detection_flags() { id TEXT PRIMARY KEY, is_auto INTEGER NOT NULL DEFAULT 0 ); + -- Only the columns the 0028 backfill reads. + CREATE TABLE repo_actions ( + id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); ", ) .unwrap(); @@ -496,7 +536,7 @@ fn test_detecting_pid_migration_clears_orphaned_detection_flags() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 27); + assert_eq!(version, 28); assert!(column_exists(&conn, "action_contexts", "detecting_pid")); // No shipped build ever cleared the flag from outside the process that set @@ -538,6 +578,14 @@ fn test_note_subtype_migration_backfills_session_less_notes() { id TEXT PRIMARY KEY, is_auto INTEGER NOT NULL DEFAULT 0 ); + -- Only the columns the 0028 backfill reads. + CREATE TABLE repo_actions ( + id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); ", ) .unwrap(); @@ -550,7 +598,7 @@ fn test_note_subtype_migration_backfills_session_less_notes() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 27); + assert_eq!(version, 28); assert!(column_exists(&conn, "notes", "subtype")); let subtype = |id: &str| -> Option { @@ -570,3 +618,82 @@ fn test_note_subtype_migration_backfills_session_less_notes() { cleanup_db(&path); } + +#[test] +fn test_pinned_actions_migration_pins_each_contexts_first_run_action() { + let path = temp_db_path("pinned-actions-backfill"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + " + PRAGMA user_version = 25; + CREATE TABLE app_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + app_version TEXT NOT NULL + ); + INSERT INTO app_metadata (id, app_version) VALUES (1, '0.2.9'); + -- Only the columns the 0028 backfill reads. + CREATE TABLE repo_actions ( + id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + action_type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + -- 'staged' is a context whose header button was the first run action; + -- the build action ahead of it never had one, and the second run action + -- lived in the Actions submenu. 'libs' has no run action at all. + -- Inserted out of sort order, and with a sort_order collision on the + -- two run actions, so the deterministic tie-break is what decides. + INSERT INTO repo_actions (id, context_id, action_type, sort_order, created_at) VALUES + ('staged-run-second', 'staged', 'run', 1, 300), + ('staged-build', 'staged', 'build', 0, 100), + ('staged-run-first', 'staged', 'run', 1, 200), + ('libs-test', 'libs', 'test', 0, 400), + ('libs-format', 'libs', 'format', 1, 500); + -- Only the table/column the 0026 auto-review cleanup targets. + CREATE TABLE reviews ( + id TEXT PRIMARY KEY, + is_auto INTEGER NOT NULL DEFAULT 0 + ); + -- Only the table the 0027 note column add targets. + CREATE TABLE notes (id TEXT PRIMARY KEY, session_id TEXT); + ", + ) + .unwrap(); + drop(conn); + + let store = Store::new(&path).unwrap(); + drop(store); + + let conn = Connection::open(&path).unwrap(); + let version: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 28); + assert!(column_exists(&conn, "repo_actions", "pinned")); + assert!(column_exists(&conn, "repo_actions", "icon")); + + let pinned: Vec = conn + .prepare("SELECT id FROM repo_actions WHERE pinned = 1 ORDER BY id ASC") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + // Exactly the action each context's header already promoted: the earliest + // run action, and nothing for the context that never had one. + assert_eq!(pinned, vec!["staged-run-first".to_string()]); + + // Nothing picks an icon on the way in — NULL is what keeps the migrated + // button rendering as the play icon it has always shown. + let icons: i64 = conn + .query_row( + "SELECT COUNT(*) FROM repo_actions WHERE icon IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(icons, 0); + + cleanup_db(&path); +} diff --git a/apps/staged/src-tauri/src/store/migrations/0028-pinned-actions/up.sql b/apps/staged/src-tauri/src/store/migrations/0028-pinned-actions/up.sql new file mode 100644 index 00000000..eabb4f8b --- /dev/null +++ b/apps/staged/src-tauri/src/store/migrations/0028-pinned-actions/up.sql @@ -0,0 +1,26 @@ +-- Which actions a card header surfaces as their own button, and what icon each +-- one shows. Until now the header's single button was the *implicit* first +-- run-type action by sort order, decided in the frontend and stored nowhere; +-- `pinned` makes that an explicit, per-action choice of any type and any count. +ALTER TABLE repo_actions ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; + +-- A kebab-case Lucide icon name ("rocket", "flask-conical"). NULL means "the +-- default icon for this action type" — which is how a detected run action keeps +-- its play button without detection ever having to pick an icon. +ALTER TABLE repo_actions ADD COLUMN icon TEXT DEFAULT NULL; + +-- Every context's current implicit main action — the first run-type action by +-- sort order, exactly what the header promoted to a button — becomes explicitly +-- pinned, so no database arrives here having silently lost its run button. The +-- icon stays NULL, which renders as the same play icon it had. `sort_order` has +-- never been unique, so the tie-break continues deterministically rather than +-- leaving the pick to whatever order the scan happens to return. +UPDATE repo_actions SET pinned = 1 WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY context_id + ORDER BY sort_order ASC, created_at ASC, id ASC + ) AS rn + FROM repo_actions WHERE action_type = 'run' + ) WHERE rn = 1 +); diff --git a/apps/staged/src-tauri/src/store/models.rs b/apps/staged/src-tauri/src/store/models.rs index 6b49a7d3..26d4f965 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -1145,6 +1145,11 @@ pub struct RepoAction { pub sort_order: i32, pub auto_commit: bool, pub run_detection_mode: Option, + /// Whether the action gets its own button in a card header. + pub pinned: bool, + /// Kebab-case Lucide icon name for that button, or `None` for the default + /// icon of the action's type. + pub icon: Option, pub created_at: i64, pub updated_at: i64, } @@ -1167,6 +1172,8 @@ impl RepoAction { sort_order, auto_commit: false, run_detection_mode: None, + pinned: false, + icon: None, created_at: now, updated_at: now, } @@ -1176,6 +1183,16 @@ impl RepoAction { self.auto_commit = auto_commit; self } + + pub fn with_pinned(mut self, pinned: bool) -> Self { + self.pinned = pinned; + self + } + + pub fn with_icon(mut self, icon: Option) -> Self { + self.icon = icon; + self + } } /// One context's actions, as grouped by the bulk `list_all_repo_actions` query. diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index 623cb336..5c05ceec 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -2613,11 +2613,57 @@ fn test_repo_actions() { let actions = store.list_repo_actions(&context.id).unwrap(); assert_eq!(actions.len(), 1); assert_eq!(actions[0].name, "Build"); + // Header presence is opt-in, and no icon means "the default for the type". + assert!(!actions[0].pinned); + assert_eq!(actions[0].icon, None); store.delete_repo_action(&action.id).unwrap(); assert!(store.list_repo_actions(&context.id).unwrap().is_empty()); } +#[test] +fn test_repo_action_round_trips_pinned_and_icon() { + let store = Store::in_memory().unwrap(); + let context = store + .get_or_create_action_context("test-owner/test-repo", None) + .unwrap(); + + let action = RepoAction::new( + context.id.clone(), + "Dev".to_string(), + "just dev".to_string(), + ActionType::Run, + 0, + ) + .with_pinned(true) + .with_icon(Some("rocket".to_string())); + store.create_repo_action(&action).unwrap(); + + let stored = store.get_repo_action(&action.id).unwrap().unwrap(); + assert!(stored.pinned); + assert_eq!(stored.icon.as_deref(), Some("rocket")); + + // Unpinning and clearing the icon both have to survive the update path — + // a NULL icon is what sends the button back to its action-type default. + let updated = RepoAction { + pinned: false, + icon: None, + ..stored + }; + store.update_repo_action(&updated).unwrap(); + + let reread = store.get_repo_action(&action.id).unwrap().unwrap(); + assert!(!reread.pinned); + assert_eq!(reread.icon, None); + + // The bulk read builds actions from its own SELECT list, so it needs the + // columns too. + let grouped = store.list_all_repo_actions().unwrap(); + let bulk = &grouped[0].actions[0]; + assert!(!bulk.pinned); + assert_eq!(bulk.icon, None); +} + #[test] fn test_list_all_repo_actions_groups_by_context() { let store = Store::in_memory().unwrap(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 262ed76a..f2983a87 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2142,6 +2142,8 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result = opt_arg(&args, "icon")?; let action = store .get_repo_action(&action_id) .map_err(|e| e.to_string())? @@ -2156,6 +2158,8 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result = opt_arg(&args, "icon")?; let context = store .get_or_create_action_context(&github_repo, subpath.as_deref()) .map_err(|e| e.to_string())?; @@ -2215,7 +2221,9 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { return invokeCommand('update_project_action', { actionId, @@ -698,6 +704,8 @@ export function updateProjectAction( actionType, sortOrder, autoCommit, + pinned, + icon, }); } @@ -760,7 +768,9 @@ export function createRepoAction( command: string, actionType: string, sortOrder: number, - autoCommit: boolean + autoCommit: boolean, + pinned: boolean, + icon: string | null ): Promise { return invokeCommand('create_repo_action', { githubRepo, @@ -770,6 +780,8 @@ export function createRepoAction( actionType, sortOrder, autoCommit, + pinned, + icon, }); } diff --git a/apps/staged/src/lib/features/actions/ActionIcon.svelte b/apps/staged/src/lib/features/actions/ActionIcon.svelte new file mode 100644 index 00000000..6e1533f4 --- /dev/null +++ b/apps/staged/src/lib/features/actions/ActionIcon.svelte @@ -0,0 +1,41 @@ + + + + diff --git a/apps/staged/src/lib/features/actions/ActionsSubmenu.svelte b/apps/staged/src/lib/features/actions/ActionsSubmenu.svelte index 08301a00..b10c13f0 100644 --- a/apps/staged/src/lib/features/actions/ActionsSubmenu.svelte +++ b/apps/staged/src/lib/features/actions/ActionsSubmenu.svelte @@ -3,7 +3,7 @@ Renders a DropdownMenu.Sub listing the runner's actions (built by buildActionMenuItems), so it must sit inside a DropdownMenu.Content. - Renders nothing when the scope has no actions beyond the primary run action. + Renders nothing when the scope has no actions beyond its pinned ones. --> + + + (query = '')} + > + + + + + + + + {#if !iconMap} +
Loading icons…
+ {:else if results.length === 0} +
No icons match "{query.trim()}"
+ {:else} +
+ {#each results as name (name)} + {@const Icon = iconMap[name]} + + {/each} +
+ {#if truncated} +
Showing the first {ICON_SEARCH_LIMIT} — keep typing to narrow.
+ {/if} + {/if} +
+
+ + diff --git a/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte b/apps/staged/src/lib/features/actions/PinnedActionButton.svelte similarity index 78% rename from apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte rename to apps/staged/src/lib/features/actions/PinnedActionButton.svelte index 75bbcff8..23b2df0e 100644 --- a/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte +++ b/apps/staged/src/lib/features/actions/PinnedActionButton.svelte @@ -1,17 +1,20 @@ -
+
{/if} @@ -248,15 +254,15 @@ {/if} diff --git a/apps/staged/src/lib/features/actions/PinnedActionCircleButton.svelte b/apps/staged/src/lib/features/actions/PinnedActionCircleButton.svelte new file mode 100644 index 00000000..7cc71922 --- /dev/null +++ b/apps/staged/src/lib/features/actions/PinnedActionCircleButton.svelte @@ -0,0 +1,73 @@ + + + + diff --git a/apps/staged/src/lib/features/actions/PinnedActionEndpointPill.svelte b/apps/staged/src/lib/features/actions/PinnedActionEndpointPill.svelte new file mode 100644 index 00000000..544ffede --- /dev/null +++ b/apps/staged/src/lib/features/actions/PinnedActionEndpointPill.svelte @@ -0,0 +1,120 @@ + + + +
+ + +
+ + diff --git a/apps/staged/src/lib/features/actions/RunningActionPills.svelte b/apps/staged/src/lib/features/actions/RunningActionPills.svelte index dc17a049..6f6b4dd1 100644 --- a/apps/staged/src/lib/features/actions/RunningActionPills.svelte +++ b/apps/staged/src/lib/features/actions/RunningActionPills.svelte @@ -6,6 +6,10 @@ run action, check/alert on completion), opens the output modal on click, and stops the action on alt-click. Driven entirely by an ActionRunner. + Status icon and tooltip come from ActionStatusIcon / actionStatusLabels, + shared with the pinned-action buttons. A pill takes the tooltip only: its own + text already names the action, so an aria-label would just talk over it. + variant selects the surface theme: 'default' is the branch card's elevated neutral pill; 'outline' is a clear background outlined with the host card's theme, reading the --accent / --card-border-hover / --card-bg-strong custom @@ -14,13 +18,11 @@