diff --git a/crates/git-core/src/git_ops.rs b/crates/git-core/src/git_ops.rs index 62307ac..4261a24 100644 --- a/crates/git-core/src/git_ops.rs +++ b/crates/git-core/src/git_ops.rs @@ -1,4 +1,6 @@ -use crate::types::{BundleInfo, Commit, PrerequisiteCheck, SafetyRef}; +use crate::types::{ + BundleInfo, Commit, InitializeRepositoryResult, PrerequisiteCheck, SafetyRef, +}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -24,6 +26,121 @@ pub fn is_git_repo(repo: &Path) -> bool { repo.join(".git").exists() } +fn is_inside_worktree(path: &Path) -> bool { + Command::new("git") + .current_dir(path) + .args(["rev-parse", "--is-inside-work-tree"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim() == "true") + .unwrap_or(false) +} + +/// Initialize a repository in one direct child of an existing parent folder. +/// A non-empty destination is reported without mutation until the caller +/// explicitly retries with `allow_non_empty`. +pub fn initialize_repository( + parent: &Path, + folder_name: &str, + initial_branch: &str, + allow_non_empty: bool, +) -> Result { + let parent = fs::canonicalize(parent) + .map_err(|error| format!("Could not open the parent folder: {error}"))?; + if !parent.is_dir() { + return Err("The selected parent path is not a folder.".to_string()); + } + + let name = folder_name.trim(); + if name.is_empty() + || name != folder_name + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + { + return Err( + "Repository name must be a single folder name without path separators." + .to_string(), + ); + } + let branch = initial_branch.trim(); + if branch.is_empty() || branch != initial_branch || branch.starts_with('-') { + return Err("Initial branch name is invalid.".to_string()); + } + let branch_check = Command::new("git") + .current_dir(&parent) + .args(["check-ref-format", "--branch", branch]) + .output() + .map_err(|error| format!("Could not validate the initial branch: {error}"))?; + if !branch_check.status.success() { + return Err("Initial branch name is invalid.".to_string()); + } + + let destination = parent.join(name); + if fs::symlink_metadata(&destination) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return Err( + "The requested repository path is a symbolic link. Choose the real folder instead." + .to_string(), + ); + } + if destination.exists() && !destination.is_dir() { + return Err("A file already exists at the requested repository path.".to_string()); + } + if destination.join(".git").exists() { + return Err("That folder is already a Git repository. Open it instead.".to_string()); + } + let nesting_probe = if destination.is_dir() { + &destination + } else { + &parent + }; + if is_inside_worktree(nesting_probe) { + return Err( + "Cannot create a nested repository inside another Git worktree. Choose a different parent folder." + .to_string(), + ); + } + + let existing_entries = if destination.is_dir() { + fs::read_dir(&destination) + .map_err(|error| format!("Could not inspect the destination folder: {error}"))? + .try_fold(0usize, |count, entry| entry.map(|_| count + 1)) + .map_err(|error| format!("Could not inspect the destination folder: {error}"))? + } else { + 0 + }; + let planned_path = destination.to_string_lossy().into_owned(); + if existing_entries > 0 && !allow_non_empty { + return Ok(InitializeRepositoryResult { + path: planned_path, + initialized: false, + existing_entries, + }); + } + + if !destination.exists() { + fs::create_dir(&destination) + .map_err(|error| format!("Could not create the repository folder: {error}"))?; + } + let mut init = Command::new("git"); + init.current_dir(&destination) + .arg("init") + .arg(format!("--initial-branch={branch}")); + run(&mut init)?; + let canonical = fs::canonicalize(&destination) + .map_err(|error| format!("Could not resolve the new repository path: {error}"))?; + Ok(InitializeRepositoryResult { + path: canonical.to_string_lossy().into_owned(), + initialized: true, + existing_entries, + }) +} + pub fn load_commits(repo: &Path, count: u32, range: Option<&str>) -> Result, String> { // Replicates: git log --no-decorate --pretty='%H%x1f%aN%x1f%aI%x1f%cN%x1f%cI%x1f%s%x1e' -n N [RANGE] // The \x1f field separator and \x1e record separator match the Python implementation. @@ -218,3 +335,93 @@ pub fn check_prerequisites(filter_repo_argv: &[String]) -> PrerequisiteCheck { filter_repo_version, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempFolder(PathBuf); + + impl TempFolder { + fn new() -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "git-it-init-test-{}-{stamp}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + + impl Drop for TempFolder { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn initialize_repository_creates_requested_folder_and_branch() { + let parent = TempFolder::new(); + + let result = initialize_repository(&parent.0, "project", "main", false).unwrap(); + + assert!(result.initialized); + assert_eq!(result.existing_entries, 0); + assert!(Path::new(&result.path).join(".git").is_dir()); + let branch = Command::new("git") + .current_dir(&result.path) + .args(["symbolic-ref", "--short", "HEAD"]) + .output() + .unwrap(); + assert!(branch.status.success()); + assert_eq!(String::from_utf8_lossy(&branch.stdout).trim(), "main"); + } + + #[test] + fn initialize_repository_requires_confirmation_for_existing_files() { + let parent = TempFolder::new(); + let destination = parent.0.join("project"); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("keep.txt"), "preserve me").unwrap(); + + let preview = initialize_repository(&parent.0, "project", "main", false).unwrap(); + assert!(!preview.initialized); + assert_eq!(preview.existing_entries, 1); + assert!(!destination.join(".git").exists()); + + let result = initialize_repository(&parent.0, "project", "main", true).unwrap(); + assert!(result.initialized); + assert_eq!( + fs::read_to_string(destination.join("keep.txt")).unwrap(), + "preserve me" + ); + } + + #[test] + fn initialize_repository_rejects_invalid_branch_before_mutation() { + let parent = TempFolder::new(); + + let err = initialize_repository(&parent.0, "project", "-danger", false).unwrap_err(); + + assert!(err.contains("branch name")); + assert!(!parent.0.join("project").exists()); + } + + #[test] + fn initialize_repository_rejects_nested_git_worktree() { + let parent = TempFolder::new(); + let mut init = Command::new("git"); + init.current_dir(&parent.0).args(["init", "-q"]); + run(&mut init).unwrap(); + + let err = initialize_repository(&parent.0, "nested", "main", false).unwrap_err(); + + assert!(err.contains("nested repository")); + assert!(!parent.0.join("nested").exists()); + } +} diff --git a/crates/git-core/src/github/mod.rs b/crates/git-core/src/github/mod.rs index 8777e99..e642b4f 100644 --- a/crates/git-core/src/github/mod.rs +++ b/crates/git-core/src/github/mod.rs @@ -955,6 +955,15 @@ pub fn issue_create(repo: &Path, title: &str, body: &str) -> Result Result { + let head = Command::new("git") + .current_dir(repo) + .args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]) + .output() + .map_err(|e| GithubError::Other(format!("could not inspect HEAD: {e}")))?; + Ok(head.status.success()) +} + /// Create a GitHub repo from this local repo, wire it as `origin`, and push the /// current branch (setting upstream). TWO steps, deliberately not gh's `--push`: /// (1) `gh repo create --source= --private|--public @@ -997,6 +1006,18 @@ pub fn create_repo( // Step 1 — create the repo + add `origin` (gh API token; reliable). let created = run_gh(&args, None)?; + // A freshly initialized repository has an unborn HEAD. The remote and + // origin are already fully created in that state, but there is no ref Git + // can push yet (`src refspec HEAD does not match any`). Treat that as a + // successful empty-repository setup and let the first normal push establish + // the upstream after the user creates a commit. + if !has_committed_head(repo)? { + return Ok(format!( + "{}\nRemote added. Create the first commit to push this repository.", + created.trim() + )); + } + // Step 2 — push the current branch + set upstream. The empty `credential.helper=` // resets any inherited (possibly broken) global helpers; the second entry forces // gh's own credential helper so auth uses the gh token. GIT_TERMINAL_PROMPT=0 so a @@ -1667,6 +1688,8 @@ pub fn runs(repo: &Path, limit: u32) -> Result, GithubError> { #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn merge_flag_maps_known_methods() { @@ -1676,6 +1699,36 @@ mod tests { assert_eq!(merge_flag("bogus"), None); } + #[test] + fn committed_head_detection_distinguishes_an_empty_repository() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "git-it-github-head-test-{}-{stamp}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + let run_git = |args: &[&str]| { + let output = Command::new("git") + .current_dir(&path) + .args(args) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + }; + run_git(&["init", "-q", "--initial-branch=main"]); + assert!(!has_committed_head(&path).unwrap()); + run_git(&["config", "user.name", "Test"]); + run_git(&["config", "user.email", "test@example.com"]); + fs::write(path.join("a.txt"), "a").unwrap(); + run_git(&["add", "a.txt"]); + run_git(&["commit", "-q", "-m", "initial"]); + assert!(has_committed_head(&path).unwrap()); + let _ = fs::remove_dir_all(path); + } + #[test] fn parses_all_github_url_forms() { let cases = [ diff --git a/crates/git-core/src/ops.rs b/crates/git-core/src/ops.rs index 36b1783..5d89f9c 100644 --- a/crates/git-core/src/ops.rs +++ b/crates/git-core/src/ops.rs @@ -686,6 +686,127 @@ fn remove_linked_worktree_for_branch( Ok(worktree.path) } +fn validate_standalone_worktree_removal(worktree: &WorktreeInfo) -> Result<(), String> { + if worktree.is_current { + return Err( + "Cannot remove the current worktree. Open this repository from another worktree first." + .to_string(), + ); + } + if worktree.is_main { + return Err("Cannot remove the repository's primary checkout.".to_string()); + } + if worktree.bare || worktree.detached || worktree.branch.is_none() { + return Err( + "Cannot safely remove a detached or unborn worktree because its commits may not be referenced by a branch." + .to_string(), + ); + } + if worktree.locked { + let detail = worktree + .locked_reason + .as_deref() + .map(|reason| format!(": {reason}")) + .unwrap_or_default(); + return Err(format!( + "The worktree is locked{detail}. Unlock it before removing it." + )); + } + if worktree.prunable { + return Err( + "The worktree is stale or missing. Prune its Git metadata before removing it." + .to_string(), + ); + } + let status = worktree.status.as_ref().ok_or_else(|| { + "Could not verify that the worktree is clean, so it was not removed.".to_string() + })?; + if status.staged > 0 + || status.unstaged > 0 + || status.untracked > 0 + || status.conflicted > 0 + || status.operation.is_some() + { + return Err( + "The worktree has uncommitted changes or an operation in progress. Commit, stash, or discard them before removing it." + .to_string(), + ); + } + Ok(()) +} + +/// Remove one clean linked worktree while preserving its branch and branch +/// configuration. The path returned by Git's authoritative worktree listing is +/// always used for deletion; the caller-provided path is only an identity key. +pub fn remove_worktree(repo: &Path, requested_path: &str) -> Result { + let initial_matches: Vec = list_worktrees(repo)? + .into_iter() + .filter(|worktree| { + same_existing_path(Path::new(&worktree.path), Path::new(requested_path)) + }) + .collect(); + if initial_matches.len() != 1 { + return Err( + "The requested worktree is no longer registered. Refresh and try again." + .to_string(), + ); + } + let initial = &initial_matches[0]; + validate_standalone_worktree_removal(initial)?; + + // Freeze HEAD before the authoritative second listing. This prevents a + // concurrent checkout, commit, reset, or merge from repurposing the target + // between validation and `git worktree remove`. + let _target_head_lock = acquire_worktree_head_lock(repo, Path::new(&initial.path))?; + let matches: Vec = list_worktrees(repo)? + .into_iter() + .filter(|worktree| { + same_existing_path(Path::new(&worktree.path), Path::new(requested_path)) + }) + .collect(); + if matches.len() != 1 { + return Err( + "The requested worktree changed while removal was being prepared. Refresh and try again." + .to_string(), + ); + } + let worktree = &matches[0]; + validate_standalone_worktree_removal(worktree)?; + + let authoritative = PathBuf::from(&worktree.path); + if !authoritative.is_absolute() { + return Err( + "Git returned a non-absolute worktree path; refusing to remove it.".to_string(), + ); + } + // HEAD.lock does not cover marker-only operations such as `git bisect + // start`, so take one final status snapshot immediately before deletion. + let final_status = graph::repo_status(&authoritative).map_err(|_| { + "Could not reverify that the worktree is clean, so it was not removed.".to_string() + })?; + if final_status.staged > 0 + || final_status.unstaged > 0 + || final_status.untracked > 0 + || final_status.conflicted > 0 + || final_status.operation.is_some() + { + return Err( + "The worktree has uncommitted changes or an operation in progress. Commit, stash, or discard them before removing it." + .to_string(), + ); + } + + let mut remove = Command::new("git"); + // Never pass --force: Git remains the final authority if the filesystem + // changes after our last snapshot. + remove + .current_dir(repo) + .args(["worktree", "remove", "--"]) + .arg(&authoritative); + git_ops::run(&mut remove)?; + Ok(worktree.path.clone()) +} + /// Switch to a branch, or check out a commit (detached HEAD). Git refuses if the /// working tree has conflicting local changes; that refusal surfaces as Err. pub fn checkout(repo: &Path, target: &str) -> Result { @@ -1123,6 +1244,71 @@ mod tests { assert!(!config.status.success(), "deleted branch config must be removed"); } + #[test] + fn remove_worktree_preserves_its_branch_and_config() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + r.git(&["config", "branch.feature.description", "keep me"]); + let canonical_wt = fs::canonicalize(&wt).unwrap(); + + let removed = remove_worktree(&r.path, wt.to_str().unwrap()).unwrap(); + + assert_eq!(Path::new(&removed), canonical_wt); + assert!(!wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + let description = Command::new("git") + .current_dir(&r.path) + .args(["config", "--get", "branch.feature.description"]) + .output() + .unwrap(); + assert!(description.status.success()); + assert_eq!(String::from_utf8_lossy(&description.stdout).trim(), "keep me"); + } + + #[test] + fn remove_worktree_refuses_dirty_linked_checkout() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + fs::write(wt.join("untracked.txt"), "do not delete").unwrap(); + + let err = remove_worktree(&r.path, wt.to_str().unwrap()).unwrap_err(); + + assert!(err.contains("uncommitted changes"), "unexpected error: {err}"); + assert!(wt.join("untracked.txt").exists()); + assert!(r.has_ref("refs/heads/feature")); + } + + #[test] + fn remove_worktree_never_removes_the_primary_checkout() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + + let err = remove_worktree(&r.path, r.path.to_str().unwrap()).unwrap_err(); + + assert!(err.contains("current worktree") || err.contains("primary checkout")); + assert!(r.path.join("a.txt").exists()); + } + #[test] fn safe_delete_falls_back_to_head_when_configured_upstream_is_gone() { let r = TempRepo::new(); diff --git a/crates/git-core/src/types.rs b/crates/git-core/src/types.rs index a0d8ca7..c7c01d5 100644 --- a/crates/git-core/src/types.rs +++ b/crates/git-core/src/types.rs @@ -111,6 +111,14 @@ pub struct RepoStatus { pub operation: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeRepositoryResult { + pub path: String, + pub initialized: bool, + pub existing_entries: usize, +} + /// One entry from `git worktree list --porcelain`, enriched with status for /// worktrees that still exist on disk. Paths remain the authoritative strings /// emitted by Git so a later destructive request can be revalidated exactly. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 75841ce..97bbe89 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,7 +7,12 @@ use git_core::ops_remote; use git_core::ops_rewrite; use git_core::ops_worktree; use git_core::rewrite; -use git_core::types::{BundleInfo, Commit, ConflictEntry, DateMapping, GraphCommit, OpOutcome, PrerequisiteCheck, RebaseOutcome, RebaseStep, Ref, ReflogEntry, RemoteInfo, RemoteOutcome, RepoStatus, RewriteOptions, RewriteResult, SafetyRef, StashEntry, WorkingFile, WorktreeInfo}; +use git_core::types::{ + BundleInfo, Commit, ConflictEntry, DateMapping, GraphCommit, InitializeRepositoryResult, + OpOutcome, PrerequisiteCheck, RebaseOutcome, RebaseStep, Ref, ReflogEntry, RemoteInfo, + RemoteOutcome, RepoStatus, RewriteOptions, RewriteResult, SafetyRef, StashEntry, WorkingFile, + WorktreeInfo, +}; use std::path::PathBuf; use std::sync::Arc; use tauri::ipc::Channel; @@ -58,6 +63,21 @@ pub fn is_git_repo(repo: String) -> bool { git_ops::is_git_repo(&PathBuf::from(repo)) } +#[tauri::command(async)] +pub fn initialize_repository( + parent: String, + folder_name: String, + initial_branch: String, + allow_non_empty: bool, +) -> Result { + git_ops::initialize_repository( + &PathBuf::from(parent), + &folder_name, + &initial_branch, + allow_non_empty, + ) +} + #[tauri::command] pub fn load_commits( repo: String, @@ -164,6 +184,11 @@ pub fn list_worktrees(repo: String) -> Result, String> { ops::list_worktrees(&PathBuf::from(repo)) } +#[tauri::command(async)] +pub fn remove_worktree(repo: String, path: String) -> Result { + ops::remove_worktree(&PathBuf::from(repo), &path) +} + #[tauri::command] pub fn repo_status(repo: String) -> Result { graph::repo_status(&PathBuf::from(repo)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4a994f..1a630e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,7 +59,7 @@ pub fn run() { // Settings panel instead. #[cfg(target_os = "macos")] { - use tauri::menu::{Menu, MenuItem}; + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::Emitter; let menu = Menu::default(app.handle())?; let settings = MenuItem::with_id( @@ -76,12 +76,36 @@ pub fn run() { true, None::<&str>, )?; + let new_repository = MenuItem::with_id( + app.handle(), + "new-repository", + "New Repository…", + true, + Some("CmdOrCtrl+N"), + )?; + let open_repository = MenuItem::with_id( + app.handle(), + "open-repository", + "Open Repository…", + true, + Some("CmdOrCtrl+O"), + )?; + let file_separator = PredefinedMenuItem::separator(app.handle())?; let items = menu.items()?; if let Some(app_menu) = items.first().and_then(|item| item.as_submenu()) { // Insert just under "About" (index 0): Settings…, then Check for Updates… app_menu.insert(&settings, 1)?; app_menu.insert(&check_updates, 2)?; } + if let Some(file_menu) = items + .iter() + .filter_map(|item| item.as_submenu()) + .find(|submenu| submenu.text().ok().as_deref() == Some("File")) + { + file_menu.insert(&new_repository, 0)?; + file_menu.insert(&open_repository, 1)?; + file_menu.insert(&file_separator, 2)?; + } app.set_menu(menu)?; app.on_menu_event(|app_handle, event| match event.id().as_ref() { "open-settings" => { @@ -90,6 +114,12 @@ pub fn run() { "check-updates" => { let _ = app_handle.emit("menu:check-updates", ()); } + "new-repository" => { + let _ = app_handle.emit("menu:new-repository", ()); + } + "open-repository" => { + let _ = app_handle.emit("menu:open-repository", ()); + } _ => {} }); } @@ -112,10 +142,12 @@ pub fn run() { commands::check_prerequisites, commands::install_command_line_tools, commands::is_git_repo, + commands::initialize_repository, commands::load_commits, commands::load_graph, commands::list_refs, commands::list_worktrees, + commands::remove_worktree, commands::repo_status, commands::checkout, commands::create_branch, diff --git a/src/lib/api.ts b/src/lib/api.ts index 1de32e6..7729629 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -41,14 +41,18 @@ import type { UpdateInfo, DownloadEvent, WorktreeInfo, + InitializeRepositoryResult, } from "./types"; -export async function pickRepoFolder(initial?: string): Promise { +export async function pickRepoFolder( + initial?: string, + title = "Select git repository root", +): Promise { const result = await open({ directory: true, multiple: false, defaultPath: initial, - title: "Select git repository root", + title, }); return typeof result === "string" ? result : null; } @@ -57,6 +61,17 @@ export const api = { checkPrerequisites: () => invoke("check_prerequisites"), installCommandLineTools: () => invoke("install_command_line_tools"), isGitRepo: (repo: string) => invoke("is_git_repo", { repo }), + initializeRepository: ( + parent: string, + folderName: string, + initialBranch: string, + allowNonEmpty = false, + ) => invoke("initialize_repository", { + parent, + folderName, + initialBranch, + allowNonEmpty, + }), // ── auto-updater (desktop only) ────────────────────────────────────────── checkUpdateOnChannel: (channel: "stable" | "beta") => invoke("check_update_on_channel", { channel }), @@ -71,6 +86,8 @@ export const api = { invoke("load_graph", { repo, count, skip }), listRefs: (repo: string) => invoke("list_refs", { repo }), listWorktrees: (repo: string) => invoke("list_worktrees", { repo }), + removeWorktree: (repo: string, path: string) => + invoke("remove_worktree", { repo, path }), repoStatus: (repo: string) => invoke("repo_status", { repo }), checkout: (repo: string, target: string) => invoke("checkout", { repo, target }), createBranch: (repo: string, name: string, startPoint: string) => diff --git a/src/lib/components/ContextMenu.svelte b/src/lib/components/ContextMenu.svelte index 648c7e1..42fab8b 100644 --- a/src/lib/components/ContextMenu.svelte +++ b/src/lib/components/ContextMenu.svelte @@ -46,6 +46,8 @@ {#each contextMenu.items as item, i (i)} {#if item.separator} + {:else if item.detail} +
{item.label}
{:else if item.submenu}
+ {:else} + Open or create a repository to get started. + {/if} +
{/if} @@ -840,6 +847,10 @@ font-size: 12px; } .empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; text-align: center; color: var(--text-muted); padding: 24px; diff --git a/src/lib/components/Modal.releaseNotes.test.ts b/src/lib/components/Modal.releaseNotes.test.ts new file mode 100644 index 0000000..6c61696 --- /dev/null +++ b/src/lib/components/Modal.releaseNotes.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { mount, tick, unmount } from "svelte"; +import { dialogs } from "../dialogs.svelte"; +import Modal from "./Modal.svelte"; + +afterEach(() => { + if (dialogs.state.kind === "confirm") dialogs.resolveConfirm(false); + document.body.replaceChildren(); +}); + +describe("confirmation message rendering", () => { + it("renders opted-in updater notes as structured, scrollable markdown", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const modal = mount(Modal, { target }); + const pending = dialogs.confirm({ + title: "Update available — 0.3.0-next.20", + message: [ + "Git It 0.3.0-next.20 is available.", + "", + "## Git It 0.3.0-next.20", + "", + "### ✨ New features", + "", + "- **General** — Add worktree visibility", + "- **UI** — Render release notes", + ].join("\n"), + messageFormat: "markdown", + confirmLabel: "Download", + }); + await tick(); + + const notes = target.querySelector("[data-testid='release-notes']"); + expect(notes).not.toBeNull(); + expect(notes?.querySelector("h2")?.textContent).toBe("Git It 0.3.0-next.20"); + expect(notes?.querySelector("h3")?.textContent).toContain("New features"); + expect(notes?.querySelectorAll("li")).toHaveLength(2); + expect(notes?.querySelector("strong")?.textContent).toBe("General"); + expect(notes?.textContent).not.toContain("## Git It"); + + dialogs.resolveConfirm(false); + await pending; + await unmount(modal); + }); + + it("keeps ordinary confirmation messages literal by default", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const modal = mount(Modal, { target }); + const pending = dialogs.confirm({ + title: "Delete branch", + message: "Delete **feature/_literal_**?", + }); + await tick(); + + expect(target.querySelector("[data-testid='release-notes']")).toBeNull(); + expect(target.querySelector(".msg")?.textContent).toBe("Delete **feature/_literal_**?"); + expect(target.querySelector(".msg strong")).toBeNull(); + + dialogs.resolveConfirm(false); + await pending; + await unmount(modal); + }); +}); diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 6aaacfa..9cfe69a 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -1,12 +1,14 @@
@@ -27,15 +105,16 @@ class="worktree" class:current={worktree.isCurrent} role="listitem" - title={`${label(worktree)} — ${worktree.path}`} + title={`${label(worktree)} — ${worktree.path}\n${worktreeStatusDetail(worktree)}${worktreeTrackingDetail(worktree, refs) ? `\n${worktreeTrackingDetail(worktree, refs)}` : ""}`} + oncontextmenu={(event) => openContextMenu(event, worktree)} > {label(worktree)} {worktree.path} - {#if worktreeStatusLabel(worktree)} - {worktreeStatusLabel(worktree)} + {#if rowState(worktree)} + {rowState(worktree)} {/if}
{/each} diff --git a/src/lib/components/WorktreePanel.test.ts b/src/lib/components/WorktreePanel.test.ts new file mode 100644 index 0000000..d50ba9b --- /dev/null +++ b/src/lib/components/WorktreePanel.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mount, tick, unmount } from "svelte"; +import type { Ref, WorktreeInfo } from "../types"; +import { contextMenu } from "../contextMenu.svelte"; + +vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn() })); +vi.mock("../store.svelte", () => ({ + appState: { openRepo: vi.fn(), setActiveView: vi.fn() }, +})); +vi.mock("../dialogs.svelte", () => ({ + dialogs: { confirm: vi.fn().mockResolvedValue(false) }, +})); +vi.mock("../gitActions", () => ({ + gitActions: { removeWorktree: vi.fn() }, +})); + +import WorktreePanel from "./WorktreePanel.svelte"; + +function worktree(overrides: Partial = {}): WorktreeInfo { + return { + path: "/tmp/linked", + head: "a".repeat(40), + branch: "feature", + isMain: false, + isCurrent: false, + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + status: { + head: { sha: "a".repeat(40), branch: "feature", detached: false }, + staged: 1, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, + }, + ...overrides, + }; +} + +afterEach(() => { + contextMenu.close(); + document.body.replaceChildren(); +}); + +describe("linked worktree panel", () => { + it("hides the primary checkout and opens an app-owned management menu", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const refs: Ref[] = [ + { name: "feature", kind: "local", target_sha: "a", upstream: "origin/feature", ahead: 2, behind: 0 }, + { name: "origin/feature", kind: "remote", target_sha: "a", upstream: null, ahead: 0, behind: 0 }, + ]; + const panel = mount(WorktreePanel, { + target, + props: { + worktrees: [worktree({ path: "/tmp/main", branch: "main", isMain: true }), worktree()], + refs, + }, + }); + await tick(); + + const rows = target.querySelectorAll(".worktree"); + expect(rows).toHaveLength(1); + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 30, + clientY: 40, + }); + rows[0].dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(contextMenu.open).toBe(true); + expect(contextMenu.items.map((item) => item.label)).toEqual(expect.arrayContaining([ + "Open Worktree in Git It", + "View Local Changes", + "Open Worktree Folder", + "Remove Worktree…", + ])); + expect(contextMenu.items.some((item) => item.label === "↑2 to push")).toBe(true); + + await unmount(panel); + }); +}); diff --git a/src/lib/contextMenu.svelte.ts b/src/lib/contextMenu.svelte.ts index 6a7d99e..706522d 100644 --- a/src/lib/contextMenu.svelte.ts +++ b/src/lib/contextMenu.svelte.ts @@ -4,6 +4,7 @@ export type MenuItem = { action?: () => void; danger?: boolean; disabled?: boolean; + detail?: boolean; separator?: boolean; submenu?: MenuItem[]; }; diff --git a/src/lib/dialogs.svelte.ts b/src/lib/dialogs.svelte.ts index c684271..ffaede2 100644 --- a/src/lib/dialogs.svelte.ts +++ b/src/lib/dialogs.svelte.ts @@ -11,6 +11,14 @@ type BranchDeleteResult = { removeWorktree: boolean; }; +export type NewRepositoryResult = { + name: string; + initialBranch: string; + createRemote: boolean; + isPrivate: boolean; + description: string; +}; + type DialogState = | { kind: "none" } | { @@ -34,6 +42,7 @@ type DialogState = kind: "confirm"; title: string; message: string; + messageFormat: "text" | "markdown"; confirmLabel: string; danger: boolean; resolve: (v: boolean) => void; @@ -66,6 +75,17 @@ type DialogState = removeWorktree: boolean; resolve: (v: BranchDeleteResult) => void; } + | { + kind: "newRepository"; + title: string; + parent: string; + name: string; + initialBranch: string; + createRemote: boolean; + isPrivate: boolean; + description: string; + resolve: (v: NewRepositoryResult | null) => void; + } | { kind: "createRepo"; title: string; @@ -96,6 +116,7 @@ function makeDialogs() { else if (state.kind === "destructive") state.resolve({ confirmed: false, backup: false }); else if (state.kind === "credentials") state.resolve(null); else if (state.kind === "branchDelete") state.resolve({ confirmed: false, force: false, deleteRemote: false, removeWorktree: false }); + else if (state.kind === "newRepository") state.resolve(null); else if (state.kind === "createRepo") state.resolve({ confirmed: false, name: "", isPrivate: true, description: "" }); else if (state.kind === "createPr") state.resolve(null); else if (state.kind === "alert") state.resolve(); @@ -128,6 +149,7 @@ function makeDialogs() { confirm(opts: { title: string; message: string; + messageFormat?: "text" | "markdown"; confirmLabel?: string; danger?: boolean; }): Promise { @@ -137,6 +159,7 @@ function makeDialogs() { kind: "confirm", title: opts.title, message: opts.message, + messageFormat: opts.messageFormat ?? "text", confirmLabel: opts.confirmLabel ?? "Confirm", danger: !!opts.danger, resolve, @@ -249,6 +272,35 @@ function makeDialogs() { state = { kind: "none" }; } }, + // ── New local repository dialog ────────────────────────────────────────── + newRepository(opts: { parent: string }): Promise { + settlePending(); + return new Promise((resolve) => { + state = { + kind: "newRepository", + title: "Create new repository", + parent: opts.parent, + name: "", + initialBranch: "main", + createRemote: false, + isPrivate: true, + description: "", + resolve, + }; + }); + }, + setNewRepositoryName(v: string) { if (state.kind === "newRepository") state = { ...state, name: v }; }, + setNewRepositoryBranch(v: string) { if (state.kind === "newRepository") state = { ...state, initialBranch: v }; }, + setNewRepositoryRemote(v: boolean) { if (state.kind === "newRepository") state = { ...state, createRemote: v }; }, + setNewRepositoryPrivate(v: boolean) { if (state.kind === "newRepository") state = { ...state, isPrivate: v }; }, + setNewRepositoryDescription(v: string) { if (state.kind === "newRepository") state = { ...state, description: v }; }, + resolveNewRepository(accepted: boolean) { + if (state.kind === "newRepository") { + const { name, initialBranch, createRemote, isPrivate, description } = state; + state.resolve(accepted ? { name, initialBranch, createRemote, isPrivate, description } : null); + state = { kind: "none" }; + } + }, // ── Create-on-GitHub dialog ──────────────────────────────────────────────── createRepo(opts: { name: string }): Promise<{ confirmed: boolean; name: string; isPrivate: boolean; description: string }> { settlePending(); diff --git a/src/lib/gitActions.partialFailure.test.ts b/src/lib/gitActions.partialFailure.test.ts index c070c9b..2a7660c 100644 --- a/src/lib/gitActions.partialFailure.test.ts +++ b/src/lib/gitActions.partialFailure.test.ts @@ -76,6 +76,79 @@ afterEach(() => { }); describe("partial branch-delete refresh", () => { + it("forgets a removed worktree path after a successful branch deletion", async () => { + mockRefreshApis(graphWithoutFeatureDecoration()); + vi.mocked(api.listWorktrees).mockRejectedValue(new Error("worktree refresh failed")); + vi.spyOn(api, "deleteBranch").mockResolvedValue(undefined); + const forgetRepo = vi.spyOn(appState, "forgetRepo"); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + false, + undefined, + undefined, + "/tmp/linked-feature", + ); + + expect(ok).toBe(true); + expect(forgetRepo).toHaveBeenCalledWith("/tmp/linked-feature"); + }); + + it("forgets a removed worktree path when a later remote deletion fails", async () => { + mockRefreshApis(graphWithoutFeatureDecoration()); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + const forgetRepo = vi.spyOn(appState, "forgetRepo"); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + "/tmp/linked-feature", + ); + + expect(ok).toBe(false); + expect(forgetRepo).toHaveBeenCalledWith("/tmp/linked-feature"); + }); + + it("keeps a worktree path when branch deletion fails before removal", async () => { + mockRefreshApis(graphWithoutFeatureDecoration()); + vi.mocked(api.listWorktrees).mockResolvedValue([ + { + path: "/tmp/linked-feature", + head: SAMPLE_GRAPH[2].sha, + branch: "feature/graph-view", + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + isCurrent: false, + isMain: false, + status: repoStatus, + }, + ]); + vi.spyOn(api, "deleteBranch").mockRejectedValue(new Error("worktree is dirty")); + const forgetRepo = vi.spyOn(appState, "forgetRepo"); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + false, + undefined, + undefined, + "/tmp/linked-feature", + ); + + expect(ok).toBe(false); + expect(forgetRepo).not.toHaveBeenCalled(); + }); + it("removes stale graph decorations without discarding queued edits", async () => { const refreshedGraph = graphWithoutFeatureDecoration(); mockRefreshApis(refreshedGraph); diff --git a/src/lib/gitActions.ts b/src/lib/gitActions.ts index 70daae0..5440ea3 100644 --- a/src/lib/gitActions.ts +++ b/src/lib/gitActions.ts @@ -703,17 +703,46 @@ export const gitActions = { run(`Create branch ${name}`, () => api.createBranch(appState.repo, name, startPoint)), renameBranch: (oldName: string, newName: string) => run(`Rename ${oldName} → ${newName}`, () => api.renameBranch(appState.repo, oldName, newName)), - deleteBranch: ( + deleteBranch: async ( name: string, force: boolean, deleteRemote = false, remote?: string, remoteBranch?: string, worktreePath?: string, - ) => - run(`Delete branch ${name}`, () => - api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch, worktreePath), - ), + ) => { + const repo = appState.repo; + const ok = await run(`Delete branch ${name}`, () => + api.deleteBranch(repo, name, force, deleteRemote, remote, remoteBranch, worktreePath), + ); + if (worktreePath && repo && isTauri()) { + if (ok) { + // Backend success proves the requested linked worktree was removed. + appState.forgetRepo(worktreePath); + } else { + try { + // The backend removes the linked worktree before a requested remote + // delete. Reconcile after an error so that partial success clears a + // stale tab, without forgetting a worktree whose removal was refused. + const worktrees = await api.listWorktrees(repo); + if (!worktrees.some((worktree) => worktree.path === worktreePath)) { + appState.forgetRepo(worktreePath); + } + } catch (error) { + console.warn("[gte] could not verify worktree removal after branch deletion", error); + } + } + } + return ok; + }, + removeWorktree: async (path: string) => { + let removedPath: string | null = null; + const ok = await run("Remove worktree", async () => { + removedPath = await api.removeWorktree(appState.repo, path); + }); + if (ok && removedPath) appState.forgetRepo(removedPath); + return ok; + }, deleteRemoteBranch: (remote: string, branch: string) => run(`Delete remote branch ${remote}/${branch}`, () => api.deleteRemoteBranch(appState.repo, remote, branch), diff --git a/src/lib/repositoryFlows.test.ts b/src/lib/repositoryFlows.test.ts new file mode 100644 index 0000000..206c858 --- /dev/null +++ b/src/lib/repositoryFlows.test.ts @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + pickRepoFolder: vi.fn(), + isGitRepo: vi.fn(), + initializeRepository: vi.fn(), + githubCurrentLogin: vi.fn(), + githubCreateRepo: vi.fn(), + newRepository: vi.fn(), + confirm: vi.fn(), + alert: vi.fn(), + refreshRefs: vi.fn(), + openRepo: vi.fn(), + setBusyOp: vi.fn(), + appState: { status: "Ready", repo: "" }, +})); + +vi.mock("./api", () => ({ + pickRepoFolder: mocks.pickRepoFolder, + api: { + isGitRepo: mocks.isGitRepo, + initializeRepository: mocks.initializeRepository, + githubCurrentLogin: mocks.githubCurrentLogin, + githubCreateRepo: mocks.githubCreateRepo, + }, +})); +vi.mock("./dialogs.svelte", () => ({ + dialogs: { + newRepository: mocks.newRepository, + confirm: mocks.confirm, + alert: mocks.alert, + }, +})); +vi.mock("./gitActions", () => ({ refreshRefs: mocks.refreshRefs })); +vi.mock("./store.svelte", () => ({ + appState: { + get status() { return mocks.appState.status; }, + set status(value: string) { mocks.appState.status = value; }, + get repo() { return mocks.appState.repo; }, + openRepo: mocks.openRepo, + setBusyOp: mocks.setBusyOp, + }, +})); + +async function loadFlows() { + vi.resetModules(); + return import("./repositoryFlows"); +} + +beforeEach(() => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); + mocks.appState.status = "Ready"; + mocks.appState.repo = ""; + mocks.pickRepoFolder.mockReset().mockResolvedValue("/projects"); + mocks.isGitRepo.mockReset().mockResolvedValue(true); + mocks.initializeRepository.mockReset().mockResolvedValue({ + path: "/projects/app", + initialized: true, + existingEntries: 0, + }); + mocks.githubCurrentLogin.mockReset().mockResolvedValue("ashproto"); + mocks.githubCreateRepo.mockReset().mockResolvedValue("https://github.com/ashproto/app"); + mocks.newRepository.mockReset().mockResolvedValue({ + name: "app", + initialBranch: "main", + createRemote: false, + isPrivate: true, + description: "", + }); + mocks.confirm.mockReset().mockResolvedValue(true); + mocks.alert.mockReset().mockResolvedValue(undefined); + mocks.refreshRefs.mockReset().mockResolvedValue(undefined); + mocks.openRepo.mockReset().mockImplementation((path: string) => { + mocks.appState.repo = path; + }); + mocks.setBusyOp.mockReset(); +}); + +afterEach(() => { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); +}); + +describe("repository creation flow", () => { + it("guards the entire flow against overlapping creation entry points", async () => { + let finishPicker!: (path: string | null) => void; + mocks.pickRepoFolder.mockImplementation( + () => new Promise((resolve) => { finishPicker = resolve; }), + ); + const { createRepositoryFlow } = await loadFlows(); + + const first = createRepositoryFlow(); + await createRepositoryFlow(); + + expect(mocks.pickRepoFolder).toHaveBeenCalledOnce(); + expect(mocks.appState.status).toBe("A repository is already being created."); + finishPicker(null); + await first; + }); + + it("creates and opens a local-only repository without touching GitHub", async () => { + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.initializeRepository).toHaveBeenCalledWith("/projects", "app", "main", false); + expect(mocks.openRepo).toHaveBeenCalledWith("/projects/app"); + expect(mocks.githubCurrentLogin).not.toHaveBeenCalled(); + expect(mocks.githubCreateRepo).not.toHaveBeenCalled(); + }); + + it("requires confirmation before initializing a non-empty folder", async () => { + mocks.initializeRepository + .mockResolvedValueOnce({ path: "/projects/app", initialized: false, existingEntries: 3 }) + .mockResolvedValueOnce({ path: "/projects/app", initialized: true, existingEntries: 3 }); + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ + title: "Folder is not empty", + confirmLabel: "Initialize Here", + })); + expect(mocks.initializeRepository).toHaveBeenNthCalledWith(2, "/projects", "app", "main", true); + expect(mocks.openRepo).toHaveBeenCalledWith("/projects/app"); + }); + + it("preflights GitHub before local mutation and creates the remote against the canonical path", async () => { + mocks.newRepository.mockResolvedValue({ + name: "app", + initialBranch: "main", + createRemote: true, + isPrivate: false, + description: "A test repository", + }); + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.githubCurrentLogin.mock.invocationCallOrder[0]).toBeLessThan( + mocks.initializeRepository.mock.invocationCallOrder[0], + ); + expect(mocks.githubCreateRepo).toHaveBeenCalledWith( + "/projects/app", + "app", + false, + "A test repository", + ); + expect(mocks.refreshRefs).toHaveBeenCalledOnce(); + }); + + it("keeps and opens the local repository when GitHub setup fails", async () => { + mocks.newRepository.mockResolvedValue({ + name: "app", + initialBranch: "main", + createRemote: true, + isPrivate: true, + description: "", + }); + mocks.githubCreateRepo.mockRejectedValue({ kind: "Other", message: "name already exists" }); + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.openRepo).toHaveBeenCalledWith("/projects/app"); + expect(mocks.alert).toHaveBeenCalledWith(expect.objectContaining({ + title: "Local repository created", + message: expect.stringContaining("name already exists"), + })); + expect(mocks.refreshRefs).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/repositoryFlows.ts b/src/lib/repositoryFlows.ts new file mode 100644 index 0000000..8e9c990 --- /dev/null +++ b/src/lib/repositoryFlows.ts @@ -0,0 +1,136 @@ +import { api, pickRepoFolder } from "./api"; +import { dialogs } from "./dialogs.svelte"; +import { refreshRefs } from "./gitActions"; +import { appState } from "./store.svelte"; + +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +let creating = false; + +function githubErrorMessage(error: unknown): string { + if (error && typeof error === "object" && "kind" in error) { + const value = error as { kind?: string; message?: string }; + if (value.kind === "NotInstalled") return "GitHub CLI is not installed. Install it with `brew install gh`."; + if (value.kind === "NotAuthed") return "GitHub CLI is not signed in. Run `gh auth login` and try again."; + if (value.kind === "RateLimited") return "GitHub's rate limit was reached. Try again later."; + if (value.kind === "Forbidden") return "GitHub refused this operation for the signed-in account."; + if (value.kind === "Other" && value.message) return value.message; + if (value.kind) return `GitHub setup failed: ${value.kind}.`; + } + return error instanceof Error ? error.message : String(error); +} + +export async function openRepositoryFlow(initial?: string): Promise { + if (!isTauri()) { + appState.status = "Opening a repo needs the desktop app."; + return; + } + const path = await pickRepoFolder(initial); + if (!path) return; + if (!(await api.isGitRepo(path))) { + appState.status = `${path} is not a git repo.`; + return; + } + appState.openRepo(path); +} + +export async function createRepositoryFlow(initial?: string): Promise { + if (!isTauri()) { + appState.status = "Creating a repo needs the desktop app."; + return; + } + if (creating) { + appState.status = "A repository is already being created."; + return; + } + creating = true; + try { + const parent = await pickRepoFolder(initial, "Choose where to create the repository"); + if (!parent) return; + const requested = await dialogs.newRepository({ parent }); + if (!requested) return; + + let createRemote = requested.createRemote; + // Authenticate before local mutation. If GitHub is unavailable, the user can + // still explicitly fall back to a local-only repository. + if (createRemote) { + try { + await api.githubCurrentLogin(); + } catch (error) { + const localOnly = await dialogs.confirm({ + title: "GitHub setup unavailable", + message: `${githubErrorMessage(error)}\n\nCreate the local repository without a remote?`, + confirmLabel: "Create Locally", + }); + if (!localOnly) return; + createRemote = false; + } + } + + appState.setBusyOp("Creating repository"); + appState.status = "Creating repository…"; + let initialized = await api.initializeRepository( + parent, + requested.name, + requested.initialBranch, + false, + ); + if (!initialized.initialized) { + appState.setBusyOp(null); + const proceed = await dialogs.confirm({ + title: "Folder is not empty", + message: `The destination already contains ${initialized.existingEntries} item${initialized.existingEntries === 1 ? "" : "s"}. Initialize Git there without changing those files?`, + confirmLabel: "Initialize Here", + }); + if (!proceed) return; + appState.setBusyOp("Creating repository"); + initialized = await api.initializeRepository( + parent, + requested.name, + requested.initialBranch, + true, + ); + } + if (!initialized.initialized) { + throw new Error("The destination could not be initialized after confirmation."); + } + + appState.openRepo(initialized.path); + if (!createRemote) { + appState.status = `Created local repository at ${initialized.path}.`; + return; + } + + appState.setBusyOp("Creating GitHub repository"); + appState.status = "Creating and connecting GitHub repository…"; + try { + await api.githubCreateRepo( + initialized.path, + requested.name, + requested.isPrivate, + requested.description, + ); + // openRepo starts its initial metadata load before the network request + // creates origin. Refresh after success so remote-dependent controls do + // not remain disabled with the pre-creation empty snapshot. + if (appState.repo === initialized.path) await refreshRefs(); + appState.status = "Created the local repository and connected its GitHub remote."; + } catch (error) { + const message = githubErrorMessage(error); + appState.status = `Local repository created; GitHub setup failed: ${message}`; + await dialogs.alert({ + title: "Local repository created", + message: `The local repository is ready at ${initialized.path}, but GitHub setup failed:\n\n${message}`, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appState.status = `Create repository failed: ${message}`; + await dialogs.alert({ title: "Create repository failed", message }); + } finally { + creating = false; + appState.setBusyOp(null); + } +} diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index d622228..185ff36 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -1409,28 +1409,33 @@ function makeState() { let autoUpdateCheckTouched = false; const ucHydrate = getStore(); - if (ucHydrate) { - ucHydrate + const updateChannelHydration: Promise = ucHydrate + ? ucHydrate .then((store) => store.get(UPDATE_CHANNEL_STORE_KEY)) .then((saved) => { if ((saved === "stable" || saved === "beta") && !updateChannelTouched) { updateChannel = saved; } }) - .catch((e) => console.warn("[gte] could not load updateChannel setting", e)); - } + .catch((e) => console.warn("[gte] could not load updateChannel setting", e)) + : Promise.resolve(); const aucHydrate = getStore(); - if (aucHydrate) { - aucHydrate + const autoUpdateCheckHydration: Promise = aucHydrate + ? aucHydrate .then((store) => store.get(AUTOUPDATE_CHECK_STORE_KEY)) .then((saved) => { if (saved !== null && saved !== undefined && !autoUpdateCheckTouched) { autoUpdateCheck = !!saved; } }) - .catch((e) => console.warn("[gte] could not load autoUpdateCheck setting", e)); - } + .catch((e) => console.warn("[gte] could not load autoUpdateCheck setting", e)) + : Promise.resolve(); + + const updateSettingsHydration = Promise.all([ + updateChannelHydration, + autoUpdateCheckHydration, + ]).then(() => undefined); function persistUpdateChannel() { const snapshot = updateChannel; @@ -2199,6 +2204,12 @@ function makeState() { autoUpdateCheck = v; persistAutoUpdateCheck(); }, + /** Startup checks must wait for both persisted updater preferences. Without + * this barrier, a beta install can briefly check the stable feed before the + * async Tauri store replaces the in-memory defaults. */ + waitForUpdateSettingsHydration(): Promise { + return updateSettingsHydration; + }, // Reads the *persisted* channel (not the in-memory default), so the updater's // first-run seed can tell "user has never chosen" from "user chose stable". // Returns null when no choice has been stored yet. @@ -2359,6 +2370,25 @@ function makeState() { persistStringList(OPENREPOS_STORE_KEY, OPENREPOS_KEY, openRepos); if (repo === path) this.repo = openRepos[idx] ?? openRepos[idx - 1] ?? openRepos[0] ?? ""; }, + + // Remove a path that no longer exists (for example, a deleted linked + // worktree) from both open tabs and recents so it cannot reopen as stale. + forgetRepo(path: string) { + if (!path) return; + const wasOpen = openRepos.includes(path); + const wasRecent = recentRepos.includes(path); + if (wasOpen) { + openRepos = openRepos.filter((entry) => entry !== path); + openReposTouched = true; + persistStringList(OPENREPOS_STORE_KEY, OPENREPOS_KEY, openRepos); + } + if (wasRecent) { + recentRepos = recentRepos.filter((entry) => entry !== path); + recentReposTouched = true; + persistStringList(RECENTREPOS_STORE_KEY, RECENTREPOS_KEY, recentRepos); + } + if (repo === path) this.repo = openRepos[0] ?? ""; + }, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 0713604..45c3bd2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -113,6 +113,12 @@ export type WorktreeInfo = { status: RepoStatus | null; }; +export type InitializeRepositoryResult = { + path: string; + initialized: boolean; + existingEntries: number; +}; + export type OpOutcome = { conflicted: boolean; files: string[]; diff --git a/src/lib/updater.svelte.ts b/src/lib/updater.svelte.ts index 610ad6d..50d9377 100644 --- a/src/lib/updater.svelte.ts +++ b/src/lib/updater.svelte.ts @@ -1,13 +1,10 @@ // In-app auto-updater flow. Desktop-only; a no-op in the browser/dev build. -// Mirrors Resume-Designer's state machine, re-surfaced through Git It's own -// surfaces: progress lands on the StatusBar (busyOp + status) and the -// download/restart prompts use dialogs.confirm. No toast library, and no -// pre-relaunch durability gate (Git It only persists settings/view-state -// fire-and-forget — there is no unsaved user document to protect). +// Progress lands on the StatusBar (busyOp + status), while download/restart +// decisions use the app's modal dialog system. import { appState } from "./store.svelte"; import { dialogs } from "./dialogs.svelte"; import { api } from "./api"; -import type { DownloadEvent } from "./types"; +import type { DownloadEvent, UpdateInfo } from "./types"; function isTauri(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -15,29 +12,55 @@ function isTauri(): boolean { const isDev = import.meta.env.DEV; let checking = false; -let lastBackgroundVersion: string | null = null; +let presenting = false; +let lastPromptedVersion: string | null = null; +let pendingAutomaticUpdate: UpdateInfo | null = null; +let pendingPromptTimer: ReturnType | null = null; let pollTimer: ReturnType | null = null; +let lastAutomaticCheckAt = 0; +let preparationPromise: Promise | null = null; + +const AUTOMATIC_CHECK_INTERVAL_MS = 30 * 60 * 1000; /** Manual "Check for Updates" (Settings button / macOS menu). */ export async function manualCheckForUpdates(): Promise { + if (!isTauri() || isDev) return; + await prepareUpdater(); await checkForUpdates("manual"); } /** Auto-check on launch — desktop + non-dev, gated on the autoUpdateCheck setting. */ export async function startupUpdateCheck(): Promise { if (!isTauri() || isDev) return; - // Seed the channel from the build type BEFORE the auto-check gate, so a fresh - // beta install lands on the beta channel even if auto-check is later turned off. - await seedChannelFromBuild(); + // Register polling before the first await. A slow settings-store read must not + // prevent this launch from ever installing its background timer. startBackgroundPolling(); + await prepareUpdater(); if (!appState.autoUpdateCheck) return; - await checkForUpdates("startup"); + await runAutomaticCheck("startup", true); +} + +/** Catch up after sleep/minimization, where WebKit may throttle interval timers. */ +export async function updateCheckOnActivate(): Promise { + if (!isTauri() || isDev) return; + await runPreparedAutomaticCheck("activation"); +} + +function prepareUpdater(): Promise { + if (!preparationPromise) { + preparationPromise = (async () => { + await appState.waitForUpdateSettingsHydration(); + // Seed only after persisted preferences are known. Every automatic entry + // point awaits this same promise, so focus/timer checks cannot race startup. + await seedChannelFromBuild(); + })(); + } + return preparationPromise; } // A beta (pre-release) build tracks the beta channel by default on first run so // it receives the rolling `next` pre-releases. Only acts when the user hasn't -// chosen a channel yet (no persisted value); never overrides a stored choice, -// and only ever flips stable → beta. +// chosen a channel yet (no persisted value); never overrides a stored choice. async function seedChannelFromBuild(): Promise { try { if ((await appState.getPersistedUpdateChannel()) !== null) return; @@ -51,53 +74,115 @@ async function seedChannelFromBuild(): Promise { } } -// Poll for updates every 30 minutes while the app is open. Notify-only, respects -// the auto-check setting live, and never runs in dev. function startBackgroundPolling(): void { if (pollTimer || isDev) return; - const THIRTY_MIN = 30 * 60 * 1000; pollTimer = setInterval(() => { - if (!appState.autoUpdateCheck) return; - void checkForUpdates("background"); - }, THIRTY_MIN); + void runPreparedAutomaticCheck("background"); + }, AUTOMATIC_CHECK_INTERVAL_MS); +} + +async function runPreparedAutomaticCheck( + source: "background" | "activation", +): Promise { + await prepareUpdater(); + if (!appState.autoUpdateCheck) return; + await tryPresentPendingAutomaticUpdate(); + await runAutomaticCheck(source); } -async function checkForUpdates(source: "manual" | "startup" | "background"): Promise { +async function runAutomaticCheck( + source: "startup" | "background" | "activation", + force = false, +): Promise { + if (!appState.autoUpdateCheck) return; + const now = Date.now(); + if (!force && now - lastAutomaticCheckAt < AUTOMATIC_CHECK_INTERVAL_MS) return; + lastAutomaticCheckAt = now; + await checkForUpdates(source); +} + +async function checkForUpdates( + source: "manual" | "startup" | "background" | "activation", +): Promise { if (!isTauri() || isDev) return; - if (checking) { - // A manual click while a check/download is already in flight: acknowledge it - // (the background/other flow owns the shared busyOp) instead of doing nothing. + if (checking || presenting) { if (source === "manual") appState.status = "Already checking for updates…"; return; } + checking = true; const manual = source === "manual"; + let available: UpdateInfo | null = null; if (manual) { appState.setBusyOp("Checking for updates"); appState.status = "Checking for updates…"; } try { - const update = await api.checkUpdateOnChannel(appState.updateChannel); - if (!update) { - if (manual) appState.status = "You are on the latest version."; - return; - } - // Background poll is notify-only: surface a non-modal status message and - // stop, so we never pop a blocking download dialog over the user's work. One - // message per new version (deduped). They install via Settings → Updates → - // Check for Updates (the manual flow). - if (source === "background") { - if (update.version === lastBackgroundVersion) return; - lastBackgroundVersion = update.version; - appState.status = `Update ${update.version} available — Settings → Updates to install.`; - return; - } + available = await api.checkUpdateOnChannel(appState.updateChannel); + if (!available && manual) appState.status = "You are on the latest version."; + } catch (err) { + reportUpdaterError(err); + } finally { + checking = false; + if (manual) appState.setBusyOp(null); + } + + if (!available) return; + if (manual) { + if (pendingAutomaticUpdate?.version === available.version) pendingAutomaticUpdate = null; + await presentUpdate(available); + } else { + queueAutomaticUpdate(available); + } +} + +function queueAutomaticUpdate(update: UpdateInfo): void { + if (update.version === lastPromptedVersion || update.version === pendingAutomaticUpdate?.version) { + return; + } + pendingAutomaticUpdate = update; + void tryPresentPendingAutomaticUpdate(); +} + +async function tryPresentPendingAutomaticUpdate(): Promise { + if (!appState.autoUpdateCheck) { + pendingAutomaticUpdate = null; + return; + } + if ( + !pendingAutomaticUpdate || + checking || + presenting || + dialogs.state.kind !== "none" + ) { + schedulePendingPromptRetry(); + return; + } + const update = pendingAutomaticUpdate; + pendingAutomaticUpdate = null; + await presentUpdate(update); +} + +function schedulePendingPromptRetry(): void { + if (!pendingAutomaticUpdate || pendingPromptTimer) return; + pendingPromptTimer = setTimeout(() => { + pendingPromptTimer = null; + void tryPresentPendingAutomaticUpdate(); + }, 1000); +} + +async function presentUpdate(update: UpdateInfo): Promise { + if (presenting) return; + presenting = true; + lastPromptedVersion = update.version; + try { const notes = (update.notes ?? "").trim(); const proceed = await dialogs.confirm({ title: `Update available — ${update.version}`, message: notes ? `Git It ${update.version} is available.\n\n${notes}` : `Git It ${update.version} is available. Download it now?`, + messageFormat: notes ? "markdown" : "text", confirmLabel: "Download", }); if (!proceed) { @@ -144,13 +229,18 @@ async function checkForUpdates(source: "manual" | "startup" | "background"): Pro clearTimeout(guard); } } catch (err) { - const raw = err instanceof Error ? err.message : String(err); - const sig = /signature|verify|verification|invalid/i.test(raw); - appState.status = sig - ? "Updater rejected the update (signature verification failed). The artifact may be unsigned or corrupted." - : `Updater error: ${raw}`; + reportUpdaterError(err); } finally { - checking = false; + presenting = false; appState.setBusyOp(null); + schedulePendingPromptRetry(); } } + +function reportUpdaterError(err: unknown): void { + const raw = err instanceof Error ? err.message : String(err); + const sig = /signature|verify|verification|invalid/i.test(raw); + appState.status = sig + ? "Updater rejected the update (signature verification failed). The artifact may be unsigned or corrupted." + : `Updater error: ${raw}`; +} diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts new file mode 100644 index 0000000..74c2882 --- /dev/null +++ b/src/lib/updater.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const appState = { + updateChannel: "stable" as "stable" | "beta", + autoUpdateCheck: true, + status: "Ready", + waitForUpdateSettingsHydration: vi.fn<() => Promise>(), + getPersistedUpdateChannel: vi.fn<() => Promise<"stable" | "beta" | null>>(), + setUpdateChannel: vi.fn<(channel: "stable" | "beta") => void>(), + setBusyOp: vi.fn<(label: string | null) => void>(), + }; + const dialogState = { kind: "none" }; + return { + appState, + dialogState, + confirm: vi.fn(), + checkUpdateOnChannel: vi.fn(), + installPendingUpdate: vi.fn(), + getVersion: vi.fn(), + }; +}); + +vi.mock("./store.svelte", () => ({ appState: mocks.appState })); +vi.mock("./dialogs.svelte", () => ({ + dialogs: { state: mocks.dialogState, confirm: mocks.confirm }, +})); +vi.mock("./api", () => ({ + api: { + checkUpdateOnChannel: mocks.checkUpdateOnChannel, + installPendingUpdate: mocks.installPendingUpdate, + }, +})); +vi.mock("@tauri-apps/api/app", () => ({ getVersion: mocks.getVersion })); + +async function loadUpdater() { + vi.stubEnv("DEV", false); + vi.resetModules(); + return import("./updater.svelte"); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-17T16:00:00-07:00")); + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); + mocks.appState.updateChannel = "stable"; + mocks.appState.autoUpdateCheck = true; + mocks.appState.status = "Ready"; + mocks.dialogState.kind = "none"; + mocks.appState.waitForUpdateSettingsHydration.mockReset().mockResolvedValue(); + mocks.appState.getPersistedUpdateChannel.mockReset().mockResolvedValue("beta"); + mocks.appState.setUpdateChannel.mockReset().mockImplementation((channel) => { + mocks.appState.updateChannel = channel; + }); + mocks.appState.setBusyOp.mockReset(); + mocks.confirm.mockReset().mockResolvedValue(false); + mocks.checkUpdateOnChannel.mockReset().mockResolvedValue(null); + mocks.installPendingUpdate.mockReset().mockResolvedValue(undefined); + mocks.getVersion.mockReset().mockResolvedValue("0.3.0-next.20"); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); +}); + +describe("automatic updater lifecycle", () => { + it("registers polling before hydration and checks the hydrated beta channel", async () => { + let finishHydration!: () => void; + mocks.appState.waitForUpdateSettingsHydration.mockImplementation( + () => new Promise((resolve) => { + finishHydration = () => { + mocks.appState.updateChannel = "beta"; + resolve(); + }; + }), + ); + const updater = await loadUpdater(); + + const startup = updater.startupUpdateCheck(); + expect(vi.getTimerCount()).toBe(1); + expect(mocks.checkUpdateOnChannel).not.toHaveBeenCalled(); + // A focus event during hydration must join the same readiness barrier, not + // run an early check against the in-memory stable default. + const activation = updater.updateCheckOnActivate(); + finishHydration(); + await Promise.all([startup, activation]); + + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledOnce(); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledWith("beta"); + }); + + it("proactively prompts once per version and opts release notes into markdown", async () => { + mocks.checkUpdateOnChannel.mockResolvedValue({ + version: "0.3.0-next.21", + currentVersion: "0.3.0-next.20", + notes: "## Fixes\n\n- **UI** — Render notes", + }); + const updater = await loadUpdater(); + + await updater.startupUpdateCheck(); + await vi.runAllTicks(); + + expect(mocks.confirm).toHaveBeenCalledOnce(); + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ + title: "Update available — 0.3.0-next.21", + messageFormat: "markdown", + confirmLabel: "Download", + })); + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledTimes(2); + expect(mocks.confirm).toHaveBeenCalledOnce(); + }); + + it("defers an automatic prompt until another dialog has closed", async () => { + mocks.dialogState.kind = "prompt"; + mocks.checkUpdateOnChannel.mockResolvedValue({ + version: "0.3.0-next.21", + currentVersion: "0.3.0-next.20", + notes: null, + }); + const updater = await loadUpdater(); + + await updater.startupUpdateCheck(); + expect(mocks.confirm).not.toHaveBeenCalled(); + + mocks.dialogState.kind = "none"; + await vi.advanceTimersByTimeAsync(1000); + expect(mocks.confirm).toHaveBeenCalledOnce(); + }); + + it("uses activation as a catch-up check after a throttled timer window", async () => { + const updater = await loadUpdater(); + await updater.startupUpdateCheck(); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledOnce(); + + vi.setSystemTime(new Date("2026-07-17T16:31:00-07:00")); + await updater.updateCheckOnActivate(); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/worktrees.test.ts b/src/lib/worktrees.test.ts index 70b75cc..ef9fc4a 100644 --- a/src/lib/worktrees.test.ts +++ b/src/lib/worktrees.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "vitest"; -import type { WorktreeInfo } from "./types"; +import type { Ref, WorktreeInfo } from "./types"; import { + linkedWorktreeForBranch, + linkedWorktrees, worktreeForBranch, worktreeIsDirty, worktreeRemovalBlocker, + worktreeStatusDetail, worktreeStatusLabel, + worktreeTrackingDetail, } from "./worktrees"; function worktree(overrides: Partial = {}): WorktreeInfo { @@ -39,6 +43,15 @@ describe("worktree helpers", () => { expect(worktreeForBranch([feature], "main")).toBeUndefined(); }); + it("keeps the primary checkout for safety lookups but excludes it from linked UI", () => { + const primary = worktree({ branch: "main", isMain: true, isCurrent: true }); + const feature = worktree(); + expect(linkedWorktrees([primary, feature])).toEqual([feature]); + expect(worktreeForBranch([primary, feature], "main")).toBe(primary); + expect(linkedWorktreeForBranch([primary, feature], "main")).toBeUndefined(); + expect(linkedWorktreeForBranch([primary, feature], "feature")).toBe(feature); + }); + it("treats file changes, conflicts, and in-progress operations as dirty", () => { expect(worktreeIsDirty(worktree())).toBe(false); expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, untracked: 1 } }))).toBe(true); @@ -49,7 +62,7 @@ describe("worktree helpers", () => { it("allows only a verified clean linked worktree to be removed", () => { expect(worktreeRemovalBlocker(worktree())).toBeNull(); expect(worktreeRemovalBlocker(worktree({ isCurrent: true }))).toContain("currently open"); - expect(worktreeRemovalBlocker(worktree({ isMain: true }))).toContain("main worktree"); + expect(worktreeRemovalBlocker(worktree({ isMain: true }))).toContain("primary checkout"); expect(worktreeRemovalBlocker(worktree({ locked: true, lockedReason: "in use" }))).toContain("in use"); expect(worktreeRemovalBlocker(worktree({ prunable: true }))).toContain("stale or missing"); expect(worktreeRemovalBlocker(worktree({ status: null }))).toContain("could not verify"); @@ -65,7 +78,23 @@ describe("worktree helpers", () => { expect(worktreeStatusLabel(worktree({ isCurrent: true, locked: true }))).toBe("Current"); expect(worktreeStatusLabel(worktree({ locked: true }))).toBe("Locked"); expect(worktreeStatusLabel(worktree({ prunable: true }))).toBe("Missing"); + expect(worktreeStatusLabel(worktree({ status: { ...worktree().status!, operation: "rebase" } }))).toBe("Rebase"); expect(worktreeStatusLabel(worktree({ status: { ...worktree().status!, staged: 1 } }))).toBe("Modified"); expect(worktreeStatusLabel(worktree())).toBeNull(); }); + + it("reports local and upstream state without conflating dimensions", () => { + const changed = worktree({ + status: { ...worktree().status!, staged: 2, unstaged: 1, untracked: 3 }, + }); + expect(worktreeStatusDetail(changed)).toBe("2 staged · 1 unstaged · 3 untracked"); + + const refs: Ref[] = [ + { name: "feature", kind: "local", target_sha: "a", upstream: "origin/feature", ahead: 2, behind: 1 }, + { name: "origin/feature", kind: "remote", target_sha: "b", upstream: null, ahead: 0, behind: 0 }, + ]; + expect(worktreeTrackingDetail(changed, refs)).toBe("↑2 to push · ↓1 to pull"); + expect(worktreeTrackingDetail(changed, refs.slice(0, 1))).toBe("Upstream missing — origin/feature"); + expect(worktreeTrackingDetail(changed, [{ ...refs[0], upstream: null }])).toBe("No upstream branch"); + }); }); diff --git a/src/lib/worktrees.ts b/src/lib/worktrees.ts index fa18fc6..f5212b4 100644 --- a/src/lib/worktrees.ts +++ b/src/lib/worktrees.ts @@ -1,4 +1,8 @@ -import type { WorktreeInfo } from "./types"; +import type { Ref, WorktreeInfo } from "./types"; + +export function linkedWorktrees(worktrees: readonly WorktreeInfo[]): WorktreeInfo[] { + return worktrees.filter((worktree) => !worktree.isMain); +} export function worktreeForBranch( worktrees: readonly WorktreeInfo[], @@ -7,6 +11,13 @@ export function worktreeForBranch( return worktrees.find((worktree) => worktree.branch === branch); } +export function linkedWorktreeForBranch( + worktrees: readonly WorktreeInfo[], + branch: string, +): WorktreeInfo | undefined { + return worktrees.find((worktree) => !worktree.isMain && worktree.branch === branch); +} + export function worktreeIsDirty(worktree: WorktreeInfo): boolean { const status = worktree.status; return !!status && ( @@ -22,7 +33,7 @@ export function worktreeIsDirty(worktree: WorktreeInfo): boolean { * every check immediately before executing; this is the explanatory UI gate. */ export function worktreeRemovalBlocker(worktree: WorktreeInfo): string | null { if (worktree.isMain) { - return "Git's main worktree cannot be removed. Check out a different branch there before deleting this branch."; + return "The repository's primary checkout cannot be removed as a linked worktree."; } if (worktree.isCurrent) { return "This is the worktree currently open in Git It. Open the repository from another worktree before removing it."; @@ -37,8 +48,11 @@ export function worktreeRemovalBlocker(worktree: WorktreeInfo): string | null { ? `This worktree is stale or missing: ${worktree.prunableReason}` : "This worktree is stale or missing. Prune its Git metadata before deleting the branch."; } - if (worktree.bare || worktree.detached || worktree.branch === null) { - return "This worktree no longer has the branch checked out. Refresh the repository and try again."; + if (worktree.detached) { + return "This detached worktree cannot be removed here because its commits may not be referenced by a branch."; + } + if (worktree.bare || worktree.branch === null) { + return "This worktree does not have a branch checked out. Refresh the repository and try again."; } if (worktree.status === null) { return "Git It could not verify that this worktree is clean, so it will not remove it."; @@ -54,8 +68,47 @@ export function worktreeStatusLabel(worktree: WorktreeInfo): string | null { if (worktree.locked) return "Locked"; if (worktree.prunable) return "Missing"; if (worktree.status === null && !worktree.bare) return "Unavailable"; + if (worktree.status?.operation) { + const operation = worktree.status.operation; + return operation.charAt(0).toUpperCase() + operation.slice(1); + } if (worktreeIsDirty(worktree)) return "Modified"; if (worktree.detached) return "Detached"; if (worktree.bare) return "Bare"; return null; } + +export function worktreeStatusDetail(worktree: WorktreeInfo): string { + if (worktree.locked) return worktree.lockedReason + ? `Locked — ${worktree.lockedReason}` + : "Locked"; + if (worktree.prunable) return worktree.prunableReason + ? `Missing — ${worktree.prunableReason}` + : "Missing from disk"; + if (!worktree.status) return "Local state unavailable"; + const parts: string[] = []; + if (worktree.status.operation) parts.push(`${worktree.status.operation} in progress`); + if (worktree.status.staged) parts.push(`${worktree.status.staged} staged`); + if (worktree.status.unstaged) parts.push(`${worktree.status.unstaged} unstaged`); + if (worktree.status.untracked) parts.push(`${worktree.status.untracked} untracked`); + if (worktree.status.conflicted) parts.push(`${worktree.status.conflicted} conflicted`); + return parts.length ? parts.join(" · ") : "Working tree clean"; +} + +export function worktreeTrackingDetail( + worktree: WorktreeInfo, + refs: readonly Ref[], +): string | null { + if (!worktree.branch) return null; + const local = refs.find((ref) => ref.kind === "local" && ref.name === worktree.branch); + if (!local) return null; + if (!local.upstream) return "No upstream branch"; + const upstreamExists = refs.some( + (ref) => ref.kind === "remote" && ref.name === local.upstream, + ); + if (!upstreamExists) return `Upstream missing — ${local.upstream}`; + const counts: string[] = []; + if (local.ahead) counts.push(`↑${local.ahead} to push`); + if (local.behind) counts.push(`↓${local.behind} to pull`); + return counts.length ? counts.join(" · ") : `Up to date with ${local.upstream}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 76d1e26..e57db74 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,7 +9,11 @@ import PrereqBanner from "$lib/components/PrereqBanner.svelte"; import SettingsPanel from "$lib/components/SettingsPanel.svelte"; import { settingsPanel } from "$lib/settingsPanel.svelte"; - import { startupUpdateCheck, manualCheckForUpdates } from "$lib/updater.svelte"; + import { + startupUpdateCheck, + manualCheckForUpdates, + updateCheckOnActivate, + } from "$lib/updater.svelte"; import ManageRepoModal from "$lib/components/ManageRepoModal.svelte"; import { manageRepo } from "$lib/manageRepo.svelte"; import BranchColorDialog from "$lib/components/BranchColorDialog.svelte"; @@ -37,7 +41,8 @@ import { graphView } from "$lib/graphView.svelte"; import { dialogs } from "$lib/dialogs.svelte"; import { anyOverlayOpen } from "$lib/overlays"; - import { pickRepoFolder, api } from "$lib/api"; + import { api } from "$lib/api"; + import { createRepositoryFlow, openRepositoryFlow } from "$lib/repositoryFlows"; import { onWindowDragMouseDown } from "$lib/tauriDrag"; import { onMount, untrack, tick } from "svelte"; import { slide } from "svelte/transition"; @@ -48,7 +53,9 @@ import { SAMPLE_GRAPH } from "$lib/graph/sample"; const currentBranch = $derived( - appState.refsByKind.local.find((r) => r.isHead)?.name ?? null, + appState.refsByKind.local.find((r) => r.isHead)?.name ?? + appState.repoStatus?.head.branch ?? + null, ); // The Fetch button swaps to a spinner + "Fetching…" while its run() op is in // flight (run() labels the op "Fetch"). Feedback lands where the user clicked. @@ -142,18 +149,7 @@ // ── Empty-state open flow ───────────────────────────────────────────────────── async function openRepoFlow() { - const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; - if (!inTauri) { - appState.status = "Opening a repo needs the desktop app."; - return; - } - const p = await pickRepoFolder(appState.repo || undefined); - if (!p) return; - if (!(await api.isGitRepo(p))) { - appState.status = `${p} is not a git repo.`; - return; - } - appState.openRepo(p); + await openRepositoryFlow(appState.repo || undefined); } // Collapse state of the two timeline panels, lifted here so the layout can give the @@ -282,6 +278,8 @@ import("@tauri-apps/api/event").then(async ({ listen }) => { unlistenMenu.push(await listen("menu:check-updates", () => manualCheckForUpdates())); unlistenMenu.push(await listen("menu:open-settings", () => settingsPanel.openPanel())); + unlistenMenu.push(await listen("menu:new-repository", () => createRepositoryFlow())); + unlistenMenu.push(await listen("menu:open-repository", () => openRepoFlow())); }); } // Reload the graph + working copy + status when the window regains focus / @@ -289,13 +287,19 @@ // Complements the filesystem watcher (which handles changes while the window // is already active). refreshActiveRepo no-ops outside Tauri / with no repo. const onActivate = () => { - if (document.visibilityState === "visible") refreshActiveRepo(); + if (document.visibilityState === "visible") { + refreshActiveRepo(); + updateCheckOnActivate(); + } }; + const suppressNativeContextMenu = (event: MouseEvent) => event.preventDefault(); window.addEventListener("focus", onActivate); document.addEventListener("visibilitychange", onActivate); + window.addEventListener("contextmenu", suppressNativeContextMenu); return () => { window.removeEventListener("focus", onActivate); document.removeEventListener("visibilitychange", onActivate); + window.removeEventListener("contextmenu", suppressNativeContextMenu); unlistenMenu.forEach((u) => u()); }; }); @@ -544,8 +548,11 @@ {#if isEmpty}
-

Open a repository to get started

- +

Open or create a repository to get started

+
+ + +
{:else}
@@ -1096,6 +1103,10 @@ font-size: 15px; color: var(--text-muted); } + .empty-actions { + display: flex; + gap: 9px; + } .open-btn { padding: 8px 20px; border-radius: 7px;