diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 96dd449c..dc8114cd 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -359,6 +359,11 @@ interface GitRemoteStatus { hasRemote: boolean } +interface GitAddRemoteResult { + status: 'connected' | 'already_configured' | 'incompatible_history' | 'auth_error' | 'network_error' | 'error' + message: string +} + interface PulseCommit { hash: string shortHash: string @@ -381,6 +386,7 @@ interface PulseCommit { | `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked | | `commit.rs` | Commit | `git add -A && git commit -m "..."` | | `remote.rs` | Pull / Push | `git pull --rebase` / `git push` | +| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe | | `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual | | `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes | @@ -400,6 +406,11 @@ interface PulseCommit { - Converts `hasRemote: false` into a local-only commit path - Keeps the normal push path unchanged for vaults that do have a remote +`AddRemoteModal` is the explicit recovery path for those local-only vaults: +- Opens from the `No remote` status-bar chip and the command palette +- Calls `git_add_remote` with the current vault path and the pasted repository URL +- Shows auth, network, and incompatible-history failures inline without rewriting the local vault's history + `useAutoGit` is the checkpoint-time companion to both hooks: - Consumes installation-local AutoGit settings (`autogit_enabled`, idle threshold, inactive threshold) - Tracks the last meaningful editor activity plus app focus/visibility transitions @@ -576,6 +587,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte `useOnboarding` hook detects first launch: - If vault path doesn't exist → show `WelcomeScreen` - User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen parent folder; Tolaria derives the final `Getting Started` child path before cloning +- After the starter repo clone completes, Tolaria removes every remote so the new vault opens local-only by default - Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback) `useGettingStartedClone` encapsulates the non-onboarding Getting Started action: @@ -593,6 +605,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte Tolaria delegates remote auth to the user's system git setup: - `CloneVaultModal` captures a remote URL and local destination - `clone_repo` shells out to system git for clone operations +- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault - Existing `git_pull` / `git_push` commands keep surfacing raw git errors - No provider-specific token or username is stored in app settings diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bbb31c28..87c94fe3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -444,6 +444,8 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition. +After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow. + ### Remote Clone & Auth Model Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration. @@ -553,6 +555,8 @@ flowchart TD `useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted. +The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds. + `useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior. #### Sync States @@ -587,7 +591,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: |--------|---------| | `vault/` | Vault scanning, caching, parsing, rename, image, migration | | `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) | -| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) | +| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) | | `search.rs` | Keyword search — walkdir-based vault file scan | | `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch | | `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing | @@ -635,6 +639,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `git_pull` | Pull from remote | | `git_push` | Push to remote | | `git_remote_status` | Get branch name + ahead/behind counts | +| `git_add_remote` | Connect a local-only vault to a compatible remote and start tracking it | | `git_resolve_conflict` | Resolve a merge conflict | | `git_commit_conflict_resolution` | Commit conflict resolution | | `get_file_history` | Last N commits for a file | @@ -705,7 +710,7 @@ if (isTauri()) { } ``` -The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits. +The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits. It also tracks per-vault remote state so browser-mode Getting Started and empty-vault flows now behave like the desktop app: local-only until `git_add_remote` succeeds. Browser smoke tests can also override `window.__mockHandlers` before the app boots. The AutoGit smoke bridge uses that path directly for seeded saves so the mocked git dirty-state stays synchronized even when the optional browser vault API is serving note content. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 42d3b335..ea6346f7 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -28,6 +28,10 @@ pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min) pnpm playwright:regression # Full Playwright regression suite ``` +## Starter Vaults And Remotes + +`create_getting_started_vault` clones the public starter repo and then removes every git remote from the new local copy. That means Getting Started vaults open local-only by default. Users connect a compatible remote later through the bottom-bar `No remote` chip or the command palette, both of which feed the same `AddRemoteModal` and `git_add_remote` backend flow. + ## Directory Structure ``` @@ -65,6 +69,7 @@ tolaria/ │ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions │ │ ├── WelcomeScreen.tsx # Onboarding screen │ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL +│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later │ │ ├── ConflictResolverModal.tsx # Git conflict resolution │ │ ├── CommitDialog.tsx # Git commit modal │ │ ├── CreateNoteDialog.tsx # New note modal @@ -154,7 +159,7 @@ tolaria/ │ │ ├── frontmatter/ # Frontmatter module │ │ │ ├── mod.rs, yaml.rs, ops.rs │ │ ├── git/ # Git module -│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs +│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs, connect.rs │ │ │ ├── conflict.rs, remote.rs, pulse.rs │ │ ├── telemetry.rs # Sentry init + path scrubber │ │ ├── search.rs # Keyword search (walkdir-based) @@ -211,6 +216,7 @@ tolaria/ | `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. | | `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. | | `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. | +| `src/components/AddRemoteModal.tsx` | Modal UI for connecting a local-only vault to a compatible remote. | | `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. | ### Backend @@ -220,7 +226,7 @@ tolaria/ | `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. | | `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. | | `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. | -| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse). | +| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). | | `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. | | `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. | | `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. | diff --git a/src-tauri/src/commands/git_connect.rs b/src-tauri/src/commands/git_connect.rs new file mode 100644 index 00000000..ccc73b55 --- /dev/null +++ b/src-tauri/src/commands/git_connect.rs @@ -0,0 +1,27 @@ +use crate::git::GitAddRemoteResult; +use serde::Deserialize; + +use super::expand_tilde; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitAddRemoteRequest { + vault_path: String, + remote_url: String, +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_add_remote(request: GitAddRemoteRequest) -> Result { + let vault_path = expand_tilde(&request.vault_path).into_owned(); + let remote_url = request.remote_url; + tokio::task::spawn_blocking(move || crate::git::git_add_remote(&vault_path, &remote_url)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_add_remote(_request: GitAddRemoteRequest) -> Result { + Err("Adding git remotes is not available on mobile".into()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c35ff28a..6d42dc9a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ mod ai; mod delete; mod git; +mod git_connect; mod system; mod vault; mod version; @@ -10,6 +11,7 @@ use std::borrow::Cow; pub use ai::*; pub use delete::*; pub use git::*; +pub use git_connect::*; pub use system::*; pub use vault::*; pub use version::*; diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index eaefe04a..58fddcc8 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -55,6 +55,7 @@ pub struct MenuStateUpdate { has_modified_files: Option, has_conflicts: Option, has_restorable_deleted_note: Option, + has_no_remote: Option, } #[cfg(desktop)] @@ -73,6 +74,9 @@ pub fn update_menu_state( if let Some(v) = state.has_restorable_deleted_note { menu::set_restore_deleted_item_enabled(&app_handle, v); } + if let Some(v) = state.has_no_remote { + menu::set_git_no_remote_items_enabled(&app_handle, v); + } Ok(()) } diff --git a/src-tauri/src/git/connect.rs b/src-tauri/src/git/connect.rs new file mode 100644 index 00000000..c85e5ea1 --- /dev/null +++ b/src-tauri/src/git/connect.rs @@ -0,0 +1,577 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::{Command, Output}; + +const DEFAULT_REMOTE_NAME: &str = "origin"; + +#[derive(Clone, Copy)] +enum ConnectStatus { + Connected, + AlreadyConfigured, + IncompatibleHistory, + AuthError, + NetworkError, + Error, +} + +impl ConnectStatus { + fn as_str(self) -> &'static str { + match self { + Self::Connected => "connected", + Self::AlreadyConfigured => "already_configured", + Self::IncompatibleHistory => "incompatible_history", + Self::AuthError => "auth_error", + Self::NetworkError => "network_error", + Self::Error => "error", + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GitAddRemoteResult { + pub status: String, // "connected" | "already_configured" | "incompatible_history" | "auth_error" | "network_error" | "error" + pub message: String, +} + +struct RemoteConnection { + branch: String, + remote_branch: String, +} + +impl RemoteConnection { + fn new(branch: String) -> Self { + let remote_branch = format!("{DEFAULT_REMOTE_NAME}/{branch}"); + Self { + branch, + remote_branch, + } + } + + fn pushed_history_message(&self) -> String { + format!( + "Remote connected. Tolaria pushed your local commits and is now tracking {}.", + self.remote_branch + ) + } + + fn tracking_message(&self) -> String { + format!( + "Remote connected. This vault now tracks {}.", + self.remote_branch + ) + } +} + +pub fn disconnect_all_remotes(vault_path: &str) -> Result<(), String> { + let vault = Path::new(vault_path); + + for remote in list_remotes(vault)? { + run_git(vault, &["remote", "remove", &remote])?; + } + + unset_upstream(vault); + Ok(()) +} + +pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result { + let vault = Path::new(vault_path); + + if remote_url.trim().is_empty() { + return Ok(connect_result( + ConnectStatus::Error, + "Enter a repository URL before connecting a remote.", + )); + } + + if !list_remotes(vault)?.is_empty() { + return Ok(connect_result( + ConnectStatus::AlreadyConfigured, + "This vault already has a remote configured.", + )); + } + + let branch = current_branch(vault)?; + if branch.is_empty() { + return Ok(connect_result( + ConnectStatus::Error, + "Tolaria could not determine the current branch for this vault.", + )); + } + let connection = RemoteConnection::new(branch); + + run_git( + vault, + &["remote", "add", DEFAULT_REMOTE_NAME, remote_url.trim()], + )?; + + let result = finish_remote_connection(vault, &connection); + if result.status != "connected" { + let _ = disconnect_all_remotes(vault_path); + } + + Ok(result) +} + +fn finish_remote_connection(vault: &Path, connection: &RemoteConnection) -> GitAddRemoteResult { + if let Err(stderr) = fetch_remote(vault) { + return classify_connect_error(&stderr); + } + + let remote_branches = match list_remote_branches(vault) { + Ok(branches) => branches, + Err(err) => return connect_result(ConnectStatus::Error, err), + }; + + if remote_branches.is_empty() { + return push_with_tracking(vault, connection, connection.pushed_history_message()); + } + + if !remote_branches + .iter() + .any(|candidate| candidate == &connection.remote_branch) + { + return connect_result( + ConnectStatus::IncompatibleHistory, + &format!( + "This repository already has git branches, but not '{}'. Use an empty repository or one created from this vault.", + connection.branch + ), + ); + } + + if !histories_share_base(vault, connection) { + return connect_result( + ConnectStatus::IncompatibleHistory, + "This repository has unrelated history. Use an empty repository or one created from this vault.", + ); + } + + let (_, behind) = match ahead_behind_counts(vault, connection) { + Ok(counts) => counts, + Err(err) => return connect_result(ConnectStatus::Error, err), + }; + + if behind > 0 { + return connect_result( + ConnectStatus::IncompatibleHistory, + &format!( + "This repository already has commits on '{}' that are not in this vault. Tolaria will not connect it automatically.", + connection.branch + ), + ); + } + + push_with_tracking(vault, connection, connection.tracking_message()) +} + +fn connect_result(status: ConnectStatus, message: impl Into) -> GitAddRemoteResult { + GitAddRemoteResult { + status: status.as_str().to_string(), + message: message.into(), + } +} + +fn current_branch(vault: &Path) -> Result { + let output = git_output(vault, &["branch", "--show-current"])?; + + if output.status.success() { + return Ok(stdout_text(&output)); + } + + Err(command_error("git branch --show-current", &output)) +} + +fn list_remotes(vault: &Path) -> Result, String> { + let output = git_output(vault, &["remote"])?; + + if !output.status.success() { + return Err(command_error("git remote", &output)); + } + + Ok(stdout_lines(&output)) +} + +fn unset_upstream(vault: &Path) { + let _ = Command::new("git") + .args(["branch", "--unset-upstream"]) + .current_dir(vault) + .output(); +} + +fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> { + let output = Command::new("git") + .args(args) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git {}: {e}", args[0]))?; + + if output.status.success() { + return Ok(()); + } + + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) +} + +fn fetch_remote(vault: &Path) -> Result<(), String> { + run_git(vault, &["fetch", DEFAULT_REMOTE_NAME, "--prune"]) +} + +fn list_remote_branches(vault: &Path) -> Result, String> { + let output = git_output( + vault, + &[ + "for-each-ref", + "--format=%(refname:short)", + "refs/remotes/origin", + ], + )?; + + if !output.status.success() { + return Err(command_error("git for-each-ref", &output)); + } + + Ok(stdout_lines(&output) + .into_iter() + .filter(|line| line != "origin/HEAD") + .collect()) +} + +fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool { + Command::new("git") + .args(["merge-base", "HEAD", connection.remote_branch.as_str()]) + .current_dir(vault) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn ahead_behind_counts(vault: &Path, connection: &RemoteConnection) -> Result<(u32, u32), String> { + let revision_range = format!("HEAD...{}", connection.remote_branch); + let output = git_output( + vault, + &["rev-list", "--left-right", "--count", &revision_range], + )?; + + if !output.status.success() { + return Err(command_error("git rev-list", &output)); + } + + let counts = stdout_text(&output); + let parts: Vec<&str> = counts.trim().split('\t').collect(); + let ahead = parts + .first() + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + let behind = parts + .get(1) + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + Ok((ahead, behind)) +} + +fn push_with_tracking( + vault: &Path, + connection: &RemoteConnection, + success_message: String, +) -> GitAddRemoteResult { + match run_git( + vault, + &[ + "push", + "-u", + DEFAULT_REMOTE_NAME, + connection.branch.as_str(), + ], + ) { + Ok(()) => connect_result(ConnectStatus::Connected, success_message), + Err(stderr) => classify_connect_error(&stderr), + } +} + +fn classify_connect_error(stderr: &str) -> GitAddRemoteResult { + let lower = stderr.to_lowercase(); + + if is_auth_error(&lower) { + return connect_result( + ConnectStatus::AuthError, + "Could not connect to that remote because git reported an authentication error. Check your credentials and try again.", + ); + } + + if is_network_error(&lower) { + return connect_result( + ConnectStatus::NetworkError, + "Could not reach that remote. Check your connection and repository URL, then try again.", + ); + } + + connect_result( + ConnectStatus::Error, + &format!( + "Could not connect that remote: {}", + concise_git_detail(stderr) + ), + ) +} + +fn git_output(vault: &Path, args: &[&str]) -> Result { + Command::new("git") + .args(args) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git {}: {e}", args[0])) +} + +fn command_error(command: &str, output: &Output) -> String { + format!("{command} failed: {}", stderr_text(output)) +} + +fn stderr_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).trim().to_string() +} + +fn stdout_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn stdout_lines(output: &Output) -> Vec { + stdout_text(output) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn is_auth_error(lower: &str) -> bool { + [ + "authentication failed", + "could not read username", + "permission denied", + "the requested url returned error: 403", + "invalid credentials", + "repository not found", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn is_network_error(lower: &str) -> bool { + [ + "could not resolve host", + "unable to access", + "connection refused", + "network is unreachable", + "timed out", + "couldn't connect", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn concise_git_detail(stderr: &str) -> String { + stderr + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("git reported an unknown error") + .trim_start_matches("fatal:") + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::tests::setup_git_repo; + use crate::git::{git_commit, git_remote_status}; + use std::fs; + use std::process::Command as StdCommand; + use tempfile::TempDir; + + fn init_bare_remote(path: &Path) { + StdCommand::new("git") + .args(["init", "--bare", "--initial-branch=main"]) + .current_dir(path) + .output() + .unwrap(); + } + + fn configure_author(path: &Path, email: &str, name: &str) { + StdCommand::new("git") + .args(["config", "user.email", email]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", name]) + .current_dir(path) + .output() + .unwrap(); + } + + fn seed_remote_history(bare_path: &Path) { + let working = TempDir::new().unwrap(); + + StdCommand::new("git") + .args(["clone", bare_path.to_str().unwrap(), "."]) + .current_dir(working.path()) + .output() + .unwrap(); + configure_author(working.path(), "remote@test.com", "Remote User"); + fs::write(working.path().join("remote.md"), "# Remote\n").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(working.path()) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "Seed remote"]) + .current_dir(working.path()) + .output() + .unwrap(); + StdCommand::new("git") + .args(["push", "origin", "main"]) + .current_dir(working.path()) + .output() + .unwrap(); + } + + fn create_local_commit(path: &Path, filename: &str, title: &str, message: &str) { + fs::write(path.join(filename), format!("# {title}\n")).unwrap(); + git_commit(path.to_str().unwrap(), message).unwrap(); + } + + #[test] + fn disconnect_all_remotes_removes_every_remote() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vault_path = vault.to_str().unwrap(); + + StdCommand::new("git") + .args(["remote", "add", "origin", "https://example.com/one.git"]) + .current_dir(vault) + .output() + .unwrap(); + StdCommand::new("git") + .args(["remote", "add", "backup", "https://example.com/two.git"]) + .current_dir(vault) + .output() + .unwrap(); + + disconnect_all_remotes(vault_path).unwrap(); + + assert!(list_remotes(vault).unwrap().is_empty()); + } + + #[test] + fn git_add_remote_connects_an_empty_remote_and_pushes_local_history() { + let local = setup_git_repo(); + configure_author(local.path(), "local@test.com", "Local User"); + create_local_commit(local.path(), "note.md", "Local", "Initial local commit"); + + let bare = TempDir::new().unwrap(); + init_bare_remote(bare.path()); + + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "connected"); + assert!(result.message.contains("tracking")); + + let status = git_remote_status(local.path().to_str().unwrap()).unwrap(); + assert!(status.has_remote); + assert_eq!((status.ahead, status.behind), (0, 0)); + } + + #[test] + fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() { + let local = setup_git_repo(); + configure_author(local.path(), "local@test.com", "Local User"); + create_local_commit(local.path(), "note.md", "Base", "Base commit"); + + let bare = TempDir::new().unwrap(); + StdCommand::new("git") + .args([ + "clone", + "--bare", + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + + create_local_commit(local.path(), "next.md", "Next", "Local follow-up"); + + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "connected"); + + let status = git_remote_status(local.path().to_str().unwrap()).unwrap(); + assert!(status.has_remote); + assert_eq!((status.ahead, status.behind), (0, 0)); + } + + #[test] + fn git_add_remote_rejects_unrelated_remote_history_and_cleans_up() { + let local = setup_git_repo(); + configure_author(local.path(), "local@test.com", "Local User"); + create_local_commit(local.path(), "note.md", "Local", "Local commit"); + + let bare = TempDir::new().unwrap(); + init_bare_remote(bare.path()); + seed_remote_history(bare.path()); + + let result = git_add_remote( + local.path().to_str().unwrap(), + bare.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(result.status, "incompatible_history"); + assert!(result.message.contains("unrelated history")); + assert!(list_remotes(local.path()).unwrap().is_empty()); + } + + #[test] + fn git_add_remote_reports_when_the_vault_is_already_remote_backed() { + let local = setup_git_repo(); + let vault = local.path(); + + StdCommand::new("git") + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(vault) + .output() + .unwrap(); + + let result = + git_add_remote(vault.to_str().unwrap(), "https://example.com/other.git").unwrap(); + + assert_eq!(result.status, "already_configured"); + } + + #[test] + fn classify_connect_error_maps_auth_failures() { + let result = classify_connect_error( + "fatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403", + ); + + assert_eq!(result.status, "auth_error"); + } + + #[test] + fn classify_connect_error_maps_network_failures() { + let result = classify_connect_error( + "fatal: unable to access 'https://github.com/org/repo.git/': Could not resolve host: github.com", + ); + + assert_eq!(result.status, "network_error"); + } +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index fbaadb6c..81dfe303 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,6 +1,7 @@ mod clone; mod commit; mod conflict; +mod connect; mod dates; mod history; mod pulse; @@ -16,6 +17,7 @@ pub use conflict::{ get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict, is_merge_in_progress, is_rebase_in_progress, }; +pub use connect::{disconnect_all_remotes, git_add_remote, GitAddRemoteResult}; pub use dates::{get_all_file_dates, GitDates}; pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history}; pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b798afd..3c7e2916 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -173,6 +173,7 @@ macro_rules! app_invoke_handler { commands::git_pull, commands::git_push, commands::git_remote_status, + commands::git_add_remote, commands::get_conflict_files, commands::get_conflict_mode, commands::git_resolve_conflict, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 5f786e03..ae335e1c 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -44,6 +44,7 @@ const NOTE_RESTORE_DELETED: &str = "note-restore-deleted"; const VAULT_OPEN: &str = "vault-open"; const VAULT_REMOVE: &str = "vault-remove"; const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started"; +const VAULT_ADD_REMOTE: &str = "vault-add-remote"; const VAULT_COMMIT_PUSH: &str = "vault-commit-push"; const VAULT_PULL: &str = "vault-pull"; const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts"; @@ -87,6 +88,7 @@ const CUSTOM_IDS: &[&str] = &[ VAULT_OPEN, VAULT_REMOVE, VAULT_RESTORE_GETTING_STARTED, + VAULT_ADD_REMOTE, VAULT_COMMIT_PUSH, VAULT_PULL, VAULT_RESOLVE_CONFLICTS, @@ -117,6 +119,9 @@ const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH]; /// IDs of menu items that depend on having merge conflicts. const GIT_CONFLICT_DEPENDENT_IDS: &[&str] = &[VAULT_RESOLVE_CONFLICTS]; +/// IDs of menu items that depend on the active vault having no remote configured. +const GIT_NO_REMOTE_DEPENDENT_IDS: &[&str] = &[VAULT_ADD_REMOTE]; + type MenuResult = Result, Box>; fn build_app_menu(app: &App) -> MenuResult { @@ -334,6 +339,10 @@ fn build_vault_menu(app: &App) -> MenuResult { let restore_getting_started = MenuItemBuilder::new("Restore Getting Started") .id(VAULT_RESTORE_GETTING_STARTED) .build(app)?; + let add_remote = MenuItemBuilder::new("Add Remote…") + .id(VAULT_ADD_REMOTE) + .enabled(false) + .build(app)?; let commit_push = MenuItemBuilder::new("Commit & Push") .id(VAULT_COMMIT_PUSH) .build(app)?; @@ -362,6 +371,7 @@ fn build_vault_menu(app: &App) -> MenuResult { .item(&remove_vault) .item(&restore_getting_started) .separator() + .item(&add_remote) .item(&commit_push) .item(&pull) .item(&resolve_conflicts) @@ -452,6 +462,11 @@ pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) { set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled); } +/// Enable or disable menu items that depend on the active vault having no remote. +pub fn set_git_no_remote_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_items_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_IDS, enabled); +} + /// Enable or disable menu items that depend on a deleted note preview being active. pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) { set_items_enabled(app_handle, RESTORE_DELETED_DEPENDENT_IDS, enabled); @@ -494,6 +509,7 @@ mod tests { VAULT_OPEN, VAULT_REMOVE, VAULT_RESTORE_GETTING_STARTED, + VAULT_ADD_REMOTE, VAULT_COMMIT_PUSH, VAULT_PULL, VAULT_RESOLVE_CONFLICTS, @@ -530,6 +546,12 @@ mod tests { "git-conflict-dependent ID {id} not in CUSTOM_IDS" ); } + for id in GIT_NO_REMOTE_DEPENDENT_IDS { + assert!( + CUSTOM_IDS.contains(id), + "git-no-remote-dependent ID {id} not in CUSTOM_IDS" + ); + } } #[test] diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index 0ef0fc7a..856346c7 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -486,6 +486,7 @@ fn create_getting_started_vault_from_repo( crate::git::clone_repo(repo_url, &target_path_str)?; let vault_path = canonical_vault_path(target_path)?; + crate::git::disconnect_all_remotes(path_to_utf8(&vault_path, "Vault path")?)?; refresh_cloned_vault_config_files(&vault_path)?; Ok(vault_path) } @@ -737,6 +738,18 @@ mod tests { assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty()); } + #[test] + fn test_create_getting_started_vault_removes_the_starter_remote() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("starter"); + let dest = dir.path().join("Getting Started"); + init_source_repo(&source, None); + + create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap(); + + assert!(!crate::git::has_remote(dest.to_str().unwrap()).unwrap()); + } + #[test] fn test_create_getting_started_vault_replaces_legacy_agents_template() { assert_getting_started_vault_replaces_template(LEGACY_AGENTS_MD); diff --git a/src/components/AddRemoteModal.test.tsx b/src/components/AddRemoteModal.test.tsx new file mode 100644 index 00000000..146e1cd1 --- /dev/null +++ b/src/components/AddRemoteModal.test.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { AddRemoteModal } from './AddRemoteModal' + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: vi.fn(), +})) + +import { mockInvoke } from '../mock-tauri' + +const mockInvokeFn = vi.mocked(mockInvoke) + +describe('AddRemoteModal', () => { + const onClose = vi.fn() + const onRemoteConnected = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockInvokeFn.mockResolvedValue({ + status: 'connected', + message: 'Remote connected. This vault now tracks origin/main.', + }) + }) + + it('renders the add-remote form when open', async () => { + render( + + ) + + expect(screen.getByText('Add Remote')).toBeInTheDocument() + const input = screen.getByTestId('add-remote-url') + await waitFor(() => expect(input).toHaveFocus()) + }) + + it('submits the repository URL to git_add_remote', async () => { + render( + + ) + + fireEvent.change(screen.getByTestId('add-remote-url'), { + target: { value: 'git@github.com:user/my-vault.git' }, + }) + fireEvent.click(screen.getByTestId('add-remote-submit')) + + await waitFor(() => { + expect(mockInvokeFn).toHaveBeenCalledWith('git_add_remote', { + request: { + vaultPath: '/vault', + remoteUrl: 'git@github.com:user/my-vault.git', + }, + }) + }) + + expect(onRemoteConnected).toHaveBeenCalledWith('Remote connected. This vault now tracks origin/main.') + expect(onClose).toHaveBeenCalledOnce() + }) + + it('shows backend validation errors without closing the modal', async () => { + mockInvokeFn.mockResolvedValueOnce({ + status: 'incompatible_history', + message: 'This repository has unrelated history.', + }) + + render( + + ) + + fireEvent.change(screen.getByTestId('add-remote-url'), { + target: { value: 'https://example.com/repo.git' }, + }) + fireEvent.click(screen.getByTestId('add-remote-submit')) + + await waitFor(() => { + expect(screen.getByTestId('add-remote-error')).toHaveTextContent('This repository has unrelated history.') + }) + + expect(onRemoteConnected).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/AddRemoteModal.tsx b/src/components/AddRemoteModal.tsx new file mode 100644 index 00000000..f852bb70 --- /dev/null +++ b/src/components/AddRemoteModal.tsx @@ -0,0 +1,171 @@ +import { useCallback, useRef, useState, type ChangeEvent, type FormEvent } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import type { GitAddRemoteResult } from '../types' +import { isTauri, mockInvoke } from '../mock-tauri' + +type ConnectState = 'idle' | 'connecting' + +interface AddRemoteModalProps { + open: boolean + vaultPath: string + onClose: () => void + onRemoteConnected: (message: string) => void | Promise +} + +function tauriCall(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(command, args) +} + +function shouldCloseAfterResult(result: GitAddRemoteResult): boolean { + return result.status === 'connected' || result.status === 'already_configured' +} + +async function submitRemoteConnection( + vaultPath: string, + remoteUrl: string, +): Promise { + return tauriCall('git_add_remote', { + request: { + vaultPath, + remoteUrl, + }, + }) +} + +async function getConnectErrorMessage({ + vaultPath, + remoteUrl, + onRemoteConnected, + onClose, +}: { + vaultPath: string + remoteUrl: string + onRemoteConnected: (message: string) => void | Promise + onClose: () => void +}): Promise { + try { + const result = await submitRemoteConnection(vaultPath, remoteUrl) + + if (shouldCloseAfterResult(result)) { + await onRemoteConnected(result.message) + onClose() + return null + } + + return result.message + } catch (error) { + return `Could not connect that remote: ${String(error)}` + } +} + +export function AddRemoteModal({ + open, + vaultPath, + onClose, + onRemoteConnected, +}: AddRemoteModalProps) { + const [remoteUrl, setRemoteUrl] = useState('') + const [connectState, setConnectState] = useState('idle') + const [connectError, setConnectError] = useState(null) + const inputRef = useRef(null) + + const resetState = useCallback(() => { + setRemoteUrl('') + setConnectState('idle') + setConnectError(null) + }, []) + + const handleClose = useCallback(() => { + resetState() + onClose() + }, [onClose, resetState]) + + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen) { + handleClose() + } + }, [handleClose]) + const handleRemoteUrlChange = useCallback((event: ChangeEvent) => { + setRemoteUrl(event.target.value) + setConnectError(null) + }, []) + + const handleSubmit = useCallback(async (event: FormEvent) => { + event.preventDefault() + + const trimmedUrl = remoteUrl.trim() + if (!trimmedUrl) return + + setConnectState('connecting') + setConnectError(null) + + const errorMessage = await getConnectErrorMessage({ + vaultPath, + remoteUrl: trimmedUrl, + onRemoteConnected, + onClose: handleClose, + }) + + if (errorMessage) { + setConnectError(errorMessage) + } + + setConnectState('idle') + }, [handleClose, onRemoteConnected, remoteUrl, vaultPath]) + + const connectDisabled = connectState === 'connecting' || !remoteUrl.trim() + + return ( + + + + Add Remote + + Connect this local vault to a git remote. Your existing local commits stay intact; Tolaria + will only connect the vault when the remote history is safe to use. + + + +
+
+ + +
+ +

+ Use an empty repository or one created from this vault. SSH keys, Git Credential Manager, + and other system git auth methods all work. +

+ + {connectError && ( +

{connectError}

+ )} + + + + +
+
+
+ ) +} diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index c407fca7..ae0cd5d7 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { act, render, screen, fireEvent } from '@testing-library/react' +import { TooltipProvider } from '@/components/ui/tooltip' import { StatusBar } from './StatusBar' +import { StatusBarPrimarySection } from './status-bar/StatusBarSections' import type { VaultOption } from './StatusBar' vi.mock('../utils/url', async () => { const actual = await vi.importActual('../utils/url') @@ -383,6 +385,28 @@ describe('StatusBar', () => { expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote') }) + it('opens the add-remote flow when clicking the no-remote chip', () => { + const onAddRemote = vi.fn() + render( + + + + ) + + fireEvent.click(screen.getByTestId('status-no-remote')) + expect(onAddRemote).toHaveBeenCalledOnce() + }) + it('calls onPullAndPush when clicking Pull required badge', () => { const onPullAndPush = vi.fn() render( diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index e85888ad..ef2b0524 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -329,9 +329,33 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) { ) } -export function NoRemoteBadge({ remoteStatus }: { remoteStatus?: GitRemoteStatus | null }) { +export function NoRemoteBadge({ + remoteStatus, + onAddRemote, +}: { + remoteStatus?: GitRemoteStatus | null + onAddRemote?: () => void +}) { if (!isRemoteMissing(remoteStatus)) return null + if (onAddRemote) { + return ( + <> + | + + + + No remote + + + + ) + } + return ( <> | diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index 88761b37..65eebf00 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -4,9 +4,11 @@ import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents' import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance' import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus' import type { McpStatus } from '../../hooks/useMcpStatus' +import { useStatusBarAddRemote } from '../../hooks/useStatusBarAddRemote' import type { GitRemoteStatus, SyncStatus } from '../../types' import { ActionTooltip } from '@/components/ui/action-tooltip' import { AiAgentsBadge } from './AiAgentsBadge' +import { AddRemoteModal } from '../AddRemoteModal' import { Button } from '@/components/ui/button' import { ClaudeCodeBadge, @@ -38,6 +40,7 @@ interface StatusBarPrimarySectionProps { onCreateEmptyVault?: () => void onCloneVault?: () => void onCloneGettingStarted?: () => void + onAddRemote?: () => void onClickPending?: () => void onClickPulse?: () => void onCommitPush?: () => void @@ -81,6 +84,7 @@ export function StatusBarPrimarySection({ onCreateEmptyVault, onCloneVault, onCloneGettingStarted, + onAddRemote, onClickPending, onClickPulse, onCommitPush, @@ -106,6 +110,19 @@ export function StatusBarPrimarySection({ claudeCodeStatus, claudeCodeVersion, }: StatusBarPrimarySectionProps) { + const { + openAddRemote, + closeAddRemote, + showAddRemote, + visibleRemoteStatus, + handleRemoteConnected, + } = useStatusBarAddRemote({ + vaultPath, + isGitVault, + remoteStatus, + onAddRemote, + }) + return (
- + { + void openAddRemote() + }} + /> - + ) : claudeCodeStatus && } +
) } diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index 4bf55a82..e973be55 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -36,6 +36,7 @@ export const APP_COMMAND_IDS = { vaultOpen: 'vault-open', vaultRemove: 'vault-remove', vaultRestoreGettingStarted: 'vault-restore-getting-started', + vaultAddRemote: 'vault-add-remote', vaultCommitPush: 'vault-commit-push', vaultPull: 'vault-pull', vaultResolveConflicts: 'vault-resolve-conflicts', @@ -92,6 +93,7 @@ type SimpleHandlerKey = | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted' + | 'onAddRemote' | 'onCommitPush' | 'onPull' | 'onResolveConflicts' @@ -294,6 +296,10 @@ export const APP_COMMAND_DEFINITIONS: Record route: { kind: 'handler', handler: 'onRestoreGettingStarted' }, menuOwned: true, }, + [APP_COMMAND_IDS.vaultAddRemote]: { + route: { kind: 'handler', handler: 'onAddRemote' }, + menuOwned: true, + }, [APP_COMMAND_IDS.vaultCommitPush]: { route: { kind: 'handler', handler: 'onCommitPush' }, menuOwned: true, diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index 0097abef..7b1d1827 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -52,6 +52,7 @@ export interface AppCommandHandlers { onOpenVault?: () => void onRemoveActiveVault?: () => void onRestoreGettingStarted?: () => void + onAddRemote?: () => void onCommitPush?: () => void onPull?: () => void onResolveConflicts?: () => void @@ -87,6 +88,7 @@ type SimpleHandlerKey = keyof Pick< | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted' + | 'onAddRemote' | 'onCommitPush' | 'onPull' | 'onResolveConflicts' @@ -124,6 +126,7 @@ const SIMPLE_HANDLER_EXECUTORS: Record handlers.onOpenVault?.(), onRemoveActiveVault: (handlers) => handlers.onRemoveActiveVault?.(), onRestoreGettingStarted: (handlers) => handlers.onRestoreGettingStarted?.(), + onAddRemote: (handlers) => handlers.onAddRemote?.(), onCommitPush: (handlers) => handlers.onCommitPush?.(), onPull: (handlers) => handlers.onPull?.(), onResolveConflicts: (handlers) => handlers.onResolveConflicts?.(), diff --git a/src/hooks/commands/gitCommands.ts b/src/hooks/commands/gitCommands.ts index e621c36a..fa8b6a89 100644 --- a/src/hooks/commands/gitCommands.ts +++ b/src/hooks/commands/gitCommands.ts @@ -3,6 +3,8 @@ import type { SidebarSelection } from '../../types' interface GitCommandsConfig { modifiedCount: number + canAddRemote: boolean + onAddRemote?: () => void onCommitPush: () => void onPull?: () => void onResolveConflicts?: () => void @@ -10,9 +12,10 @@ interface GitCommandsConfig { } export function buildGitCommands(config: GitCommandsConfig): CommandAction[] { - const { modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect } = config + const { modifiedCount, canAddRemote, onAddRemote, onCommitPush, onPull, onResolveConflicts, onSelect } = config return [ { id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush }, + { id: 'add-remote', label: 'Add Remote to Current Vault', group: 'Git', keywords: ['git', 'remote', 'connect', 'origin', 'no remote'], enabled: canAddRemote && !!onAddRemote, execute: () => onAddRemote?.() }, { id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() }, { id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() }, { id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) }, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 4f188054..9ce1638e 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -7,6 +7,7 @@ import type { CommandAction } from './useCommandRegistry' import { useKeyboardNavigation } from './useKeyboardNavigation' import { useMenuEvents } from './useMenuEvents' import type { SidebarSelection, SidebarFilter, VaultEntry } from '../types' +import { requestAddRemote } from '../utils/addRemoteEvents' import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' import type { NoteListMultiSelectionCommands } from '../components/note-list/multiSelectionCommands' @@ -52,6 +53,8 @@ interface AppCommandsConfig { canGoForward?: boolean onOpenVault?: () => void onCreateEmptyVault?: () => void + onAddRemote?: () => void + canAddRemote?: boolean onCreateType?: () => void onToggleAIChat?: () => void onCheckForUpdates?: () => void @@ -88,6 +91,90 @@ interface AppCommandsConfig { canRestoreDeletedNote?: boolean } +type CommandRegistryConfig = Parameters[0] +type CommandRegistrySelectionState = Pick< + CommandRegistryConfig, + | 'activeNoteModified' + | 'onZoomIn' + | 'onZoomOut' + | 'onZoomReset' + | 'zoomLevel' + | 'onSelect' + | 'showInbox' + | 'onGoBack' + | 'onGoForward' + | 'canGoBack' + | 'canGoForward' + | 'selection' +> +type CommandRegistryCoreActions = Pick< + CommandRegistryConfig, + | 'activeTabPath' + | 'entries' + | 'modifiedCount' + | 'onQuickOpen' + | 'onCreateNote' + | 'onCreateNoteOfType' + | 'onSave' + | 'onOpenSettings' + | 'onOpenFeedback' + | 'onDeleteNote' + | 'onArchiveNote' + | 'onUnarchiveNote' + | 'onCommitPush' + | 'onPull' + | 'onResolveConflicts' + | 'onSetViewMode' + | 'onToggleInspector' + | 'onToggleDiff' + | 'onToggleRawEditor' + | 'onToggleAIChat' +> +type CommandRegistryVaultActions = Pick< + CommandRegistryConfig, + | 'onOpenVault' + | 'onCreateEmptyVault' + | 'onAddRemote' + | 'canAddRemote' + | 'onCheckForUpdates' + | 'onCreateType' + | 'onRemoveActiveVault' + | 'onRestoreGettingStarted' + | 'isGettingStartedHidden' + | 'vaultCount' + | 'onReloadVault' + | 'onRepairVault' + | 'onOpenInNewWindow' + | 'onRestoreDeletedNote' + | 'canRestoreDeletedNote' +> +type CommandRegistryAiActions = Pick< + CommandRegistryConfig, + | 'mcpStatus' + | 'onInstallMcp' + | 'aiAgentsStatus' + | 'vaultAiGuidanceStatus' + | 'onOpenAiAgents' + | 'onRestoreVaultAiGuidance' + | 'onSetDefaultAiAgent' + | 'selectedAiAgent' + | 'onCycleDefaultAiAgent' + | 'selectedAiAgentLabel' +> +type CommandRegistryNoteActions = Pick< + CommandRegistryConfig, + | 'onSetNoteIcon' + | 'onRemoveNoteIcon' + | 'activeNoteHasIcon' + | 'noteListFilter' + | 'onSetNoteListFilter' + | 'onToggleFavorite' + | 'onToggleOrganized' + | 'onCustomizeNoteListColumns' + | 'canCustomizeNoteListColumns' + | 'noteListColumnsLabel' +> + function createKeyboardActions( config: AppCommandsConfig, ): Omit[0], 'onArchiveNote'> { @@ -121,6 +208,40 @@ function createMenuEventHandlers( selectFilter: (filter: SidebarFilter) => void, viewChanges: () => void, ): Omit[0], 'onArchiveNote'> { + return { + ...createMenuEventActionHandlers(config, selectFilter), + ...createMenuEventVaultHandlers(config, viewChanges), + ...createMenuEventState(config), + } +} + +function createMenuEventActionHandlers( + config: AppCommandsConfig, + selectFilter: (filter: SidebarFilter) => void, +): Pick< + Omit[0], 'onArchiveNote'>, + | 'onSetViewMode' + | 'onCreateNote' + | 'onCreateType' + | 'onQuickOpen' + | 'onSave' + | 'onOpenSettings' + | 'onToggleInspector' + | 'onCommandPalette' + | 'onZoomIn' + | 'onZoomOut' + | 'onZoomReset' + | 'onDeleteNote' + | 'onSearch' + | 'onToggleRawEditor' + | 'onToggleDiff' + | 'onToggleAIChat' + | 'onToggleOrganized' + | 'onGoBack' + | 'onGoForward' + | 'onCheckForUpdates' + | 'onSelectFilter' +> { return { onSetViewMode: config.onSetViewMode, onCreateNote: config.onCreateNote, @@ -143,9 +264,33 @@ function createMenuEventHandlers( onGoForward: config.onGoForward, onCheckForUpdates: config.onCheckForUpdates, onSelectFilter: selectFilter, + } +} + +function createMenuEventVaultHandlers( + config: AppCommandsConfig, + viewChanges: () => void, +): Pick< + Omit[0], 'onArchiveNote'>, + | 'onOpenVault' + | 'onRemoveActiveVault' + | 'onRestoreGettingStarted' + | 'onAddRemote' + | 'onCommitPush' + | 'onPull' + | 'onResolveConflicts' + | 'onViewChanges' + | 'onInstallMcp' + | 'onReloadVault' + | 'onRepairVault' + | 'onOpenInNewWindow' + | 'onRestoreDeletedNote' +> { + return { onOpenVault: config.onOpenVault, onRemoveActiveVault: config.onRemoveActiveVault, onRestoreGettingStarted: config.onRestoreGettingStarted, + onAddRemote: config.onAddRemote ?? requestAddRemote, onCommitPush: config.onCommitPush, onPull: config.onPull, onResolveConflicts: config.onResolveConflicts, @@ -155,15 +300,52 @@ function createMenuEventHandlers( onRepairVault: config.onRepairVault, onOpenInNewWindow: config.onOpenInNewWindow, onRestoreDeletedNote: config.onRestoreDeletedNote, + } +} + +function createMenuEventState( + config: AppCommandsConfig, +): Pick< + Omit[0], 'onArchiveNote'>, + | 'activeTabPathRef' + | 'multiSelectionCommandRef' + | 'activeTabPath' + | 'modifiedCount' + | 'hasRestorableDeletedNote' + | 'hasNoRemote' +> { + return { activeTabPathRef: config.activeTabPathRef, multiSelectionCommandRef: config.multiSelectionCommandRef, activeTabPath: config.activeTabPath, modifiedCount: config.modifiedCount, hasRestorableDeletedNote: config.canRestoreDeletedNote, + hasNoRemote: config.canAddRemote ?? true, } } -function createCommandRegistryConfig(config: AppCommandsConfig): Parameters[0] { +function createCommandRegistrySelectionConfig( + config: AppCommandsConfig, +): CommandRegistrySelectionState { + return { + activeNoteModified: config.activeNoteModified, + onZoomIn: config.onZoomIn, + onZoomOut: config.onZoomOut, + onZoomReset: config.onZoomReset, + zoomLevel: config.zoomLevel, + onSelect: config.onSelect, + showInbox: config.showInbox, + onGoBack: config.onGoBack, + onGoForward: config.onGoForward, + canGoBack: config.canGoBack, + canGoForward: config.canGoForward, + selection: config.selection, + } +} + +function createCommandRegistryCoreConfig( + config: AppCommandsConfig, +): CommandRegistryCoreActions { return { activeTabPath: config.activeTabPath, entries: config.entries, @@ -185,25 +367,35 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters void onOpenVault?: () => void onCreateEmptyVault?: () => void + onAddRemote?: () => void + canAddRemote?: boolean onCreateType?: () => void onDeleteNote: (path: string) => void onArchiveNote: (path: string) => void @@ -139,7 +141,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onToggleOrganized, isOrganized: activeEntry?.organized ?? false, onRestoreDeletedNote, canRestoreDeletedNote, }), - ...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }), + ...buildGitCommands({ + modifiedCount, + canAddRemote: config.canAddRemote ?? false, + onAddRemote: config.onAddRemote, + onCommitPush, + onPull, + onResolveConflicts, + onSelect, + }), ...buildViewCommands({ hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset, @@ -166,7 +176,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified, onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, - onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault, + onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault, config.canAddRemote, config.onAddRemote, onCheckForUpdates, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index edbcfc28..35c1fcec 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -45,6 +45,7 @@ function makeHandlers(): MenuEventHandlers { onOpenVault: vi.fn(), onRemoveActiveVault: vi.fn(), onRestoreGettingStarted: vi.fn(), + onAddRemote: vi.fn(), onCommitPush: vi.fn(), onPull: vi.fn(), onResolveConflicts: vi.fn(), @@ -57,6 +58,7 @@ function makeHandlers(): MenuEventHandlers { multiSelectionCommandRef: { current: null }, activeTabPath: '/vault/test.md', hasRestorableDeletedNote: false, + hasNoRemote: false, } } @@ -154,6 +156,12 @@ describe('dispatchMenuEvent', () => { expect(h.onCommandPalette).toHaveBeenCalled() }) + it('vault-add-remote triggers the add-remote flow', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-add-remote', h) + expect(h.onAddRemote).toHaveBeenCalled() + }) + it('view-zoom-in triggers zoom in', () => { const h = makeHandlers() dispatchMenuEvent('view-zoom-in', h) diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index a6263d65..e6b393e5 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -12,6 +12,7 @@ export interface MenuEventHandlers extends AppCommandHandlers { modifiedCount?: number conflictCount?: number hasRestorableDeletedNote?: boolean + hasNoRemote?: boolean } interface MenuStatePayload { @@ -19,6 +20,7 @@ interface MenuStatePayload { hasModifiedFiles?: boolean hasConflicts?: boolean hasRestorableDeletedNote?: boolean + hasNoRemote?: boolean } function readCustomEventDetail(event: Event): string | null { @@ -129,6 +131,7 @@ export function useMenuEvents(handlers: MenuEventHandlers) { const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined const hasRestorableDeletedNote = handlers.hasRestorableDeletedNote + const hasNoRemote = handlers.hasNoRemote useEffect(() => { ref.current = handlers @@ -142,5 +145,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) { hasModifiedFiles, hasConflicts, hasRestorableDeletedNote, + hasNoRemote, }) } diff --git a/src/hooks/useStatusBarAddRemote.ts b/src/hooks/useStatusBarAddRemote.ts new file mode 100644 index 00000000..f155facd --- /dev/null +++ b/src/hooks/useStatusBarAddRemote.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { GitRemoteStatus } from '../types' +import { REQUEST_ADD_REMOTE_EVENT } from '../utils/addRemoteEvents' + +function tauriCall(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(command, args) +} + +async function readRemoteStatus(vaultPath: string): Promise { + try { + return await tauriCall('git_remote_status', { vaultPath }) + } catch { + return null + } +} + +interface UseStatusBarAddRemoteOptions { + vaultPath: string + isGitVault: boolean + remoteStatus?: GitRemoteStatus | null + onAddRemote?: () => void +} + +interface StatusBarAddRemoteState { + openAddRemote: () => Promise + closeAddRemote: () => void + showAddRemote: boolean + visibleRemoteStatus: GitRemoteStatus | null + handleRemoteConnected: (message: string) => Promise +} + +interface RemoteStatusOverride { + vaultPath: string + status: GitRemoteStatus | null +} + +export function useStatusBarAddRemote({ + vaultPath, + isGitVault, + remoteStatus, + onAddRemote, +}: UseStatusBarAddRemoteOptions): StatusBarAddRemoteState { + const [showAddRemote, setShowAddRemote] = useState(false) + const [remoteStatusOverride, setRemoteStatusOverride] = useState(null) + + const refreshRemoteStatus = useCallback(async () => { + const latestStatus = await readRemoteStatus(vaultPath) + if (latestStatus) { + setRemoteStatusOverride({ vaultPath, status: latestStatus }) + } + return latestStatus + }, [vaultPath]) + + const openAddRemote = useCallback(async () => { + if (onAddRemote) { + onAddRemote() + return + } + + if (!isGitVault) return + + const latestStatus = await refreshRemoteStatus() + if (latestStatus?.hasRemote) { + setShowAddRemote(false) + return + } + + setShowAddRemote(true) + }, [isGitVault, onAddRemote, refreshRemoteStatus]) + + const closeAddRemote = useCallback(() => { + setShowAddRemote(false) + }, []) + + const handleRemoteConnected = useCallback(async (message: string) => { + void message + await refreshRemoteStatus() + }, [refreshRemoteStatus]) + + useEffect(() => { + const handleRequest = () => { + void openAddRemote() + } + + window.addEventListener(REQUEST_ADD_REMOTE_EVENT, handleRequest) + return () => window.removeEventListener(REQUEST_ADD_REMOTE_EVENT, handleRequest) + }, [openAddRemote]) + + const visibleRemoteStatus = useMemo( + () => ( + remoteStatusOverride?.vaultPath === vaultPath + ? remoteStatusOverride.status + : remoteStatus ?? null + ), + [remoteStatus, remoteStatusOverride, vaultPath], + ) + + return { + openAddRemote, + closeAddRemote, + showAddRemote, + visibleRemoteStatus, + handleRemoteConnected, + } +} diff --git a/src/mock-tauri/mock-handlers.test.ts b/src/mock-tauri/mock-handlers.test.ts new file mode 100644 index 00000000..49cc255f --- /dev/null +++ b/src/mock-tauri/mock-handlers.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { mockHandlers } from './mock-handlers' + +describe('mockHandlers git remote state', () => { + it('keeps starter vaults local-only until a remote is added', () => { + const vaultPath = '/Users/mock/Documents/Getting Started Test' + + expect(mockHandlers.create_getting_started_vault({ targetPath: vaultPath })).toBe(vaultPath) + expect(mockHandlers.git_remote_status({ vaultPath }).hasRemote).toBe(false) + + expect( + mockHandlers.git_add_remote({ + request: { + vaultPath, + remoteUrl: 'https://example.com/starter.git', + }, + }).status, + ).toBe('connected') + + expect(mockHandlers.git_remote_status({ vaultPath }).hasRemote).toBe(true) + }) + + it('starts empty vaults without a remote and keeps cloned vaults remote-backed', () => { + const emptyVaultPath = '/Users/mock/Documents/Local Vault' + const clonedVaultPath = '/Users/mock/Documents/Cloned Vault' + + expect(mockHandlers.create_empty_vault({ targetPath: emptyVaultPath })).toBe(emptyVaultPath) + expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath }).hasRemote).toBe(false) + + expect(mockHandlers.clone_repo({ url: 'https://example.com/repo.git', localPath: clonedVaultPath })).toContain(clonedVaultPath) + expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath }).hasRemote).toBe(true) + }) +}) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index c17bd4f7..24b750e6 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -3,7 +3,17 @@ * Each handler simulates a Tauri backend command. */ -import type { VaultEntry, ModifiedFile, Settings, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, PulseCommit } from '../types' +import type { + VaultEntry, + ModifiedFile, + Settings, + GitAddRemoteResult, + GitPullResult, + GitPushResult, + GitRemoteStatus, + LastCommitInfo, + PulseCommit, +} from '../types' import { MOCK_CONTENT } from './mock-content' import { MOCK_ENTRIES } from './mock-entries' @@ -110,6 +120,9 @@ const DEFAULT_MOCK_VAULT = { } let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH +const mockRemoteStateByVault: Record = { + [DEFAULT_MOCK_VAULT_PATH]: true, +} let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = { vaults: [DEFAULT_MOCK_VAULT], @@ -122,6 +135,23 @@ let mockVaultAiGuidanceStatus = { can_restore: false, } as const +function normalizeMockVaultPath(path: string | null | undefined): string | null { + const trimmed = path?.trim() + return trimmed ? trimmed : null +} + +function setMockRemoteState(path: string | null | undefined, hasRemote: boolean): void { + const normalizedPath = normalizeMockVaultPath(path) + if (!normalizedPath) return + mockRemoteStateByVault[normalizedPath] = hasRemote +} + +function getMockRemoteState(path: string | null | undefined): boolean { + const normalizedPath = normalizeMockVaultPath(path) + if (!normalizedPath) return true + return mockRemoteStateByVault[normalizedPath] ?? true +} + function escapeRegex({ text }: { text: string }) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -273,7 +303,24 @@ export const mockHandlers: Record any> = { get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }), git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }), git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }), - git_remote_status: (): GitRemoteStatus => ({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }), + git_remote_status: (args?: { vaultPath?: string; vault_path?: string }): GitRemoteStatus => { + const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH + return { branch: 'main', ahead: 0, behind: 0, hasRemote: getMockRemoteState(vaultPath) } + }, + git_add_remote: (args?: { + request?: { vaultPath?: string; vault_path?: string; remoteUrl?: string } + vaultPath?: string + vault_path?: string + remoteUrl?: string + }): GitAddRemoteResult => { + const request = args?.request ?? args ?? {} + const vaultPath = request.vaultPath ?? request.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH + setMockRemoteState(vaultPath, true) + return { + status: 'connected', + message: 'Remote connected. This vault now tracks origin/main.', + } + }, get_vault_pulse: (args: { limit?: number }): PulseCommit[] => { const limit = args.limit ?? 30 const ts = Math.floor(Date.now() / 1000) @@ -339,7 +386,11 @@ export const mockHandlers: Record any> = { save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null }, rename_note: handleRenameNote, rename_note_filename: handleRenameNoteFilename, - clone_repo: (args: { url: string; localPath?: string; local_path?: string }) => `Cloned to ${args.localPath ?? args.local_path ?? ''}`, + clone_repo: (args: { url: string; localPath?: string; local_path?: string }) => { + const localPath = args.localPath ?? args.local_path ?? '' + setMockRemoteState(localPath, true) + return `Cloned to ${localPath}` + }, purge_trash: () => [], delete_note: (args: { path: string }) => args.path, batch_delete_notes: (args: { paths: string[] }) => args.paths, @@ -372,8 +423,16 @@ export const mockHandlers: Record any> = { // In mock mode, the demo-vault-v2 path always "exists" return args.path.includes('demo-vault-v2') }, - create_empty_vault: (args: { targetPath?: string; target_path?: string }) => args.targetPath || args.target_path || '/Users/mock/Documents/My Vault', - create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started', + create_empty_vault: (args: { targetPath?: string; target_path?: string }) => { + const targetPath = args.targetPath || args.target_path || '/Users/mock/Documents/My Vault' + setMockRemoteState(targetPath, false) + return targetPath + }, + create_getting_started_vault: (args: { targetPath?: string | null }) => { + const targetPath = args.targetPath || '/Users/mock/Documents/Getting Started' + setMockRemoteState(targetPath, false) + return targetPath + }, register_mcp_tools: () => 'registered', check_mcp_status: () => 'installed', repair_vault: (): string => { diff --git a/src/types.ts b/src/types.ts index ae104390..d69a2bc8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,11 @@ export interface GitPushResult { message: string } +export interface GitAddRemoteResult { + status: 'connected' | 'already_configured' | 'incompatible_history' | 'auth_error' | 'network_error' | 'error' + message: string +} + export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict' | 'pull_required' export interface GitRemoteStatus { diff --git a/src/utils/addRemoteEvents.ts b/src/utils/addRemoteEvents.ts new file mode 100644 index 00000000..670431bd --- /dev/null +++ b/src/utils/addRemoteEvents.ts @@ -0,0 +1,5 @@ +export const REQUEST_ADD_REMOTE_EVENT = 'tolaria:add-remote-request' + +export function requestAddRemote(): void { + window.dispatchEvent(new Event(REQUEST_ADD_REMOTE_EVENT)) +} diff --git a/tests/smoke/add-remote-from-no-remote.spec.ts b/tests/smoke/add-remote-from-no-remote.spec.ts new file mode 100644 index 00000000..0342eabd --- /dev/null +++ b/tests/smoke/add-remote-from-no-remote.spec.ts @@ -0,0 +1,159 @@ +import { test, expect, type Page } from '@playwright/test' + +const COMMAND_INPUT = 'input[placeholder="Type a command..."]' + +type Handler = (args?: Record) => unknown +type RemoteArgs = { + request?: { + vaultPath?: string + remoteUrl?: string + } + vaultPath?: string + remoteUrl?: string +} +type BrowserWindow = Window & + typeof globalThis & { + __mockHandlers?: Record + __remoteConnected?: boolean + __addRemoteCalls?: string[] + } + +async function openCommandPalette(page: Page): Promise { + await page.keyboard.press('Control+k') + await expect(page.locator(COMMAND_INPUT)).toBeVisible() +} + +async function executeCommand(page: Page, name: string): Promise { + await page.locator(COMMAND_INPUT).fill(name) + await expect(page.locator('[data-selected="true"]').first()).toContainText(name, { + timeout: 2_000, + }) + await page.keyboard.press('Enter') +} + +async function focusStatusChip(page: Page, testId: string): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + await page.keyboard.press('Tab') + const focused = await page.evaluate((expected) => { + const active = document.activeElement as HTMLElement | null + return active?.dataset?.testid === expected + }, testId) + + if (focused) return + } + + throw new Error(`Could not focus ${testId} with keyboard navigation`) +} + +function installRemoteMocksScript() { + const browserWindow = window as BrowserWindow + browserWindow.__remoteConnected = false + browserWindow.__addRemoteCalls = [] + + const applyRemoteMockOverrides = (handlers?: Record | null) => { + if (!handlers) return handlers ?? null + + handlers.git_remote_status = () => ({ + branch: 'main', + ahead: 0, + behind: 0, + hasRemote: browserWindow.__remoteConnected ?? false, + }) + + handlers.git_add_remote = (args?: RemoteArgs) => { + const request = args?.request ?? args ?? {} + const remoteUrl = request.remoteUrl ?? '' + browserWindow.__addRemoteCalls = [ + ...(browserWindow.__addRemoteCalls ?? []), + remoteUrl, + ] + + if (remoteUrl === 'https://example.com/safe.git') { + browserWindow.__remoteConnected = true + return { + status: 'connected', + message: 'Remote connected. This vault now tracks origin/main.', + } + } + + return { + status: 'incompatible_history', + message: 'This repository has unrelated history.', + } + } + + return handlers + } + + let ref = applyRemoteMockOverrides(browserWindow.__mockHandlers) ?? null + Object.defineProperty(browserWindow, '__mockHandlers', { + configurable: true, + set(value) { + ref = applyRemoteMockOverrides(value as Record | undefined) ?? null + }, + get() { + return applyRemoteMockOverrides(ref) ?? ref + }, + }) +} + +async function installRemoteMocks(page: Page): Promise { + await page.addInitScript(installRemoteMocksScript) +} + +async function openAddRemoteFromStatusChip(page: Page): Promise { + await expect(page.getByTestId('status-no-remote')).toContainText('No remote') + await focusStatusChip(page, 'status-no-remote') + await page.keyboard.press('Enter') + await expect(page.getByTestId('add-remote-modal')).toBeVisible() + await expect(page.getByTestId('add-remote-url')).toBeFocused() + await page.keyboard.press('Escape') + await expect(page.getByTestId('add-remote-modal')).toHaveCount(0) +} + +async function connectRemoteFromCommandPalette(page: Page, remoteUrl: string): Promise { + await openCommandPalette(page) + await executeCommand(page, 'Add Remote to Current Vault') + + await expect(page.getByTestId('add-remote-modal')).toBeVisible() + await expect(page.getByTestId('add-remote-url')).toBeFocused() + + await page.keyboard.type(remoteUrl) + await page.keyboard.press('Tab') + await expect(page.getByTestId('add-remote-submit')).toBeFocused() + await page.keyboard.press('Enter') + + await expect(page.getByTestId('status-no-remote')).toHaveCount(0) + await expect + .poll(async () => + page.evaluate( + () => (window as Window & { __addRemoteCalls?: string[] }).__addRemoteCalls?.length ?? 0, + ), + ) + .toBe(1) +} + +async function assertCommitPushDialogStillOpens(page: Page): Promise { + await openCommandPalette(page) + await executeCommand(page, 'Commit & Push') + + await expect(page.getByRole('heading', { name: 'Commit & Push' })).toBeVisible() + await expect( + page.getByText( + 'Review changed files and enter a commit message before committing and pushing.', + ), + ).toBeVisible() +} + +test('keyboard-only add remote flow connects a local-only vault @smoke', async ({ + page, +}) => { + await installRemoteMocks(page) + + await page.goto('/') + await page.waitForLoadState('networkidle') + + await openAddRemoteFromStatusChip(page) + await connectRemoteFromCommandPalette(page, 'https://example.com/safe.git') + await assertCommitPushDialogStillOpens(page) +})