diff --git a/apps/staged/src-tauri/src/actions/commands.rs b/apps/staged/src-tauri/src/actions/commands.rs index a70aaf9c1..678a57d38 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 0028 +/// 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 3a48186cf..9c70f3add 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 8d6812242..8d8ad23c6 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 5378229b4..9b98af0e4 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 000000000..eabb4f8b4 --- /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 6b49a7d34..26d4f9653 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 623cb3367..5c05ceece 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 262ed76ac..f2983a87d 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 000000000..6e1533f46 --- /dev/null +++ b/apps/staged/src/lib/features/actions/ActionIcon.svelte @@ -0,0 +1,41 @@ + + + + diff --git a/apps/staged/src/lib/features/actions/ActionStatusIcon.svelte b/apps/staged/src/lib/features/actions/ActionStatusIcon.svelte new file mode 100644 index 000000000..e12efe521 --- /dev/null +++ b/apps/staged/src/lib/features/actions/ActionStatusIcon.svelte @@ -0,0 +1,57 @@ + + + +{#if stopping} + +{:else if showStop} + +{:else if serving} + +{:else if running} + +{:else if status === 'completed'} + +{:else if status === 'failed'} + +{:else} + {@render idle?.()} +{/if} diff --git a/apps/staged/src/lib/features/actions/ActionsSubmenu.svelte b/apps/staged/src/lib/features/actions/ActionsSubmenu.svelte index 08301a005..b10c13f00 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/PinnedActionButton.svelte b/apps/staged/src/lib/features/actions/PinnedActionButton.svelte new file mode 100644 index 000000000..1516f85f1 --- /dev/null +++ b/apps/staged/src/lib/features/actions/PinnedActionButton.svelte @@ -0,0 +1,121 @@ + + + +{#if show} +
+ {#if showPill} + + {:else} + + {/if} +
+{/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 000000000..7cc71922a --- /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 000000000..544ffede0 --- /dev/null +++ b/apps/staged/src/lib/features/actions/PinnedActionEndpointPill.svelte @@ -0,0 +1,120 @@ + + + +
+ + +
+ + diff --git a/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte b/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte deleted file mode 100644 index 75bbcff82..000000000 --- a/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte +++ /dev/null @@ -1,280 +0,0 @@ - - - -{#if show && primaryRunAction} - {@const execution = runner.primaryActionExecution} - {@const isRunning = execution?.status === 'running'} - {@const isStopping = execution && runner.stoppingExecutions.has(execution.executionId)} - {@const showStopIcon = altKey.held && isRunning && !isStopping} - {@const phase = execution ? runner.runPhases.get(execution.executionId) : undefined} - {@const hasEndpoint = phase?.type === 'running' && !!phase.endpoint && canResolveEndpoint} - {@const copyUrl = - hasEndpoint && phase?.type === 'running' && phase.endpoint - ? getEndpointCopyUrl(phase.endpoint) - : ''} -
- {#if isRunning && hasEndpoint && phase?.type === 'running' && phase.endpoint} - -
- - -
- {:else} - - - {/if} -
-{/if} - - diff --git a/apps/staged/src/lib/features/actions/RunningActionPills.svelte b/apps/staged/src/lib/features/actions/RunningActionPills.svelte index 219f3fa23..6f6b4dd17 100644 --- a/apps/staged/src/lib/features/actions/RunningActionPills.svelte +++ b/apps/staged/src/lib/features/actions/RunningActionPills.svelte @@ -1,11 +1,15 @@ @@ -18,7 +18,7 @@ import MoreVertical from '@lucide/svelte/icons/more-vertical'; import ActionOutputModal from '../actions/ActionOutputModal.svelte'; import ActionsSubmenu from '../actions/ActionsSubmenu.svelte'; - import PrimaryRunActionButton from '../actions/PrimaryRunActionButton.svelte'; + import PinnedActionButton from '../actions/PinnedActionButton.svelte'; import RunningActionPills from '../actions/RunningActionPills.svelte'; import { ActionRunner } from '../actions/actionRunner.svelte'; import type { MenuItem } from '../actions/actionMenu'; @@ -218,10 +218,18 @@ ); - + {#if isLocal || (isRemote && remoteWorkspaceStatus === 'running')} - + {#each runner.pinnedActions as action (action.id)} + + {/each} {/if} {#snippet renderSubItems(items: MenuItem[])} {#each items as item, i (i)} diff --git a/apps/staged/src/lib/features/projects/RepoCard.svelte b/apps/staged/src/lib/features/projects/RepoCard.svelte index 3de9cb0a4..e0a551f93 100644 --- a/apps/staged/src/lib/features/projects/RepoCard.svelte +++ b/apps/staged/src/lib/features/projects/RepoCard.svelte @@ -6,7 +6,7 @@ The card is the full repo path (rendered by the shared RepoLabel, wrapped over as many lines as it needs) above a row of actions: a labelled "Add project" button on the left, then — right-aligned — the action-runner surfaces (running - pills and the primary run button) or a clone button, the pin toggle, and a + pills and a button per pinned action) or a clone button, the pin toggle, and a more menu carrying every repo action, an Actions submenu, the local-clone openers, and a Repo Settings jump to this repo's entry in Settings → Repos. Card tint, border and accent all come from the repo's badge hue. @@ -60,7 +60,7 @@ import { Button } from '$lib/components/ui/button'; import ActionOutputModal from '../actions/ActionOutputModal.svelte'; import ActionsSubmenu from '../actions/ActionsSubmenu.svelte'; - import PrimaryRunActionButton from '../actions/PrimaryRunActionButton.svelte'; + import PinnedActionButton from '../actions/PinnedActionButton.svelte'; import RunningActionPills from '../actions/RunningActionPills.svelte'; import { ActionRunner } from '../actions/actionRunner.svelte'; import { bulkRepoActions, bulkRunningForScope } from '../actions/repoActionsBulk'; @@ -360,7 +360,9 @@ --card-bg-strong vars set on the card root, so the runner surfaces pick up the repo's badge hue. --> - + {#each runner.pinnedActions as action (action.id)} + + {/each} {#if actionsLoaded && runner.actions.length === 0}