feat: keep getting started vaults local by default

This commit is contained in:
lucaronin
2026-04-19 16:18:35 +02:00
parent 36c20f34f4
commit 374b4f6686
29 changed files with 1654 additions and 34 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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<GitAddRemoteResult, String> {
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<GitAddRemoteResult, String> {
Err("Adding git remotes is not available on mobile".into())
}

View File

@@ -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::*;

View File

@@ -55,6 +55,7 @@ pub struct MenuStateUpdate {
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
has_no_remote: Option<bool>,
}
#[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(())
}

View File

@@ -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<GitAddRemoteResult, String> {
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<String>) -> GitAddRemoteResult {
GitAddRemoteResult {
status: status.as_str().to_string(),
message: message.into(),
}
}
fn current_branch(vault: &Path) -> Result<String, String> {
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<Vec<String>, 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<Vec<String>, 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<Output, String> {
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<String> {
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");
}
}

View File

@@ -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};

View File

@@ -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,

View File

@@ -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<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
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]

View File

@@ -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);

View File

@@ -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(
<AddRemoteModal
open={true}
vaultPath="/vault"
onClose={onClose}
onRemoteConnected={onRemoteConnected}
/>
)
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(
<AddRemoteModal
open={true}
vaultPath="/vault"
onClose={onClose}
onRemoteConnected={onRemoteConnected}
/>
)
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(
<AddRemoteModal
open={true}
vaultPath="/vault"
onClose={onClose}
onRemoteConnected={onRemoteConnected}
/>
)
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()
})
})

View File

@@ -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<void>
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function shouldCloseAfterResult(result: GitAddRemoteResult): boolean {
return result.status === 'connected' || result.status === 'already_configured'
}
async function submitRemoteConnection(
vaultPath: string,
remoteUrl: string,
): Promise<GitAddRemoteResult> {
return tauriCall<GitAddRemoteResult>('git_add_remote', {
request: {
vaultPath,
remoteUrl,
},
})
}
async function getConnectErrorMessage({
vaultPath,
remoteUrl,
onRemoteConnected,
onClose,
}: {
vaultPath: string
remoteUrl: string
onRemoteConnected: (message: string) => void | Promise<void>
onClose: () => void
}): Promise<string | null> {
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<ConnectState>('idle')
const [connectError, setConnectError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
setRemoteUrl(event.target.value)
setConnectError(null)
}, [])
const handleSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) => {
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[520px]" data-testid="add-remote-modal">
<DialogHeader>
<DialogTitle>Add Remote</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4 py-2" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground" htmlFor="add-remote-url">Repository URL</label>
<Input
id="add-remote-url"
ref={inputRef}
autoFocus
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
value={remoteUrl}
onChange={handleRemoteUrlChange}
data-testid="add-remote-url"
/>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
Use an empty repository or one created from this vault. SSH keys, Git Credential Manager,
and other system git auth methods all work.
</p>
{connectError && (
<p className="text-xs text-destructive" data-testid="add-remote-error">{connectError}</p>
)}
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
<Button type="submit" disabled={connectDisabled} data-testid="add-remote-submit">
{connectState === 'connecting' ? 'Connecting...' : 'Connect Remote'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -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(
<TooltipProvider>
<StatusBarPrimarySection
modifiedCount={0}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onAddRemote={onAddRemote}
syncStatus="idle"
lastSyncTime={null}
conflictCount={0}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
</TooltipProvider>
)
fireEvent.click(screen.getByTestId('status-no-remote'))
expect(onAddRemote).toHaveBeenCalledOnce()
})
it('calls onPullAndPush when clicking Pull required badge', () => {
const onPullAndPush = vi.fn()
render(

View File

@@ -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 (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarAction
copy={{ label: 'Add a remote to this vault' }}
onClick={onAddRemote}
testId="status-no-remote"
>
<span style={ICON_STYLE}>
<GitBranch size={12} />
No remote
</span>
</StatusBarAction>
</>
)
}
return (
<>
<span style={SEP_STYLE}>|</span>

View File

@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
<VaultMenu
@@ -137,13 +154,18 @@ export function StatusBarPrimarySection({
</Button>
</ActionTooltip>
<OfflineBadge isOffline={isOffline} />
<NoRemoteBadge remoteStatus={remoteStatus} />
<NoRemoteBadge
remoteStatus={visibleRemoteStatus}
onAddRemote={() => {
void openAddRemote()
}}
/>
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} remoteStatus={remoteStatus} />
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} />
<SyncBadge
status={syncStatus}
lastSyncTime={lastSyncTime}
remoteStatus={remoteStatus}
remoteStatus={visibleRemoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
@@ -162,6 +184,12 @@ export function StatusBarPrimarySection({
/>
)
: claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
<AddRemoteModal
open={showAddRemote}
vaultPath={vaultPath}
onClose={closeAddRemote}
onRemoteConnected={handleRemoteConnected}
/>
</div>
)
}

View File

@@ -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<AppCommandId, AppCommandDefinition>
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,

View File

@@ -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<SimpleHandlerKey, (handlers: AppCommandHa
onOpenVault: (handlers) => 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?.(),

View File

@@ -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' }) },

View File

@@ -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<typeof useCommandRegistry>[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<Parameters<typeof useAppKeyboard>[0], 'onArchiveNote'> {
@@ -121,6 +208,40 @@ function createMenuEventHandlers(
selectFilter: (filter: SidebarFilter) => void,
viewChanges: () => void,
): Omit<Parameters<typeof useMenuEvents>[0], 'onArchiveNote'> {
return {
...createMenuEventActionHandlers(config, selectFilter),
...createMenuEventVaultHandlers(config, viewChanges),
...createMenuEventState(config),
}
}
function createMenuEventActionHandlers(
config: AppCommandsConfig,
selectFilter: (filter: SidebarFilter) => void,
): Pick<
Omit<Parameters<typeof useMenuEvents>[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<Parameters<typeof useMenuEvents>[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<Parameters<typeof useMenuEvents>[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<typeof useCommandRegistry>[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<type
onToggleDiff: config.onToggleDiff,
onToggleRawEditor: config.onToggleRawEditor,
onToggleAIChat: config.onToggleAIChat,
}
}
function createCommandRegistryVaultConfig(
config: AppCommandsConfig,
): CommandRegistryVaultActions {
return {
onOpenVault: config.onOpenVault,
onCreateEmptyVault: config.onCreateEmptyVault,
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,
onAddRemote: config.onAddRemote ?? requestAddRemote,
canAddRemote: config.canAddRemote ?? true,
onCheckForUpdates: config.onCheckForUpdates,
onCreateType: config.onCreateType,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,
vaultCount: config.vaultCount,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onOpenInNewWindow: config.onOpenInNewWindow,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
}
}
function createCommandRegistryAiConfig(
config: AppCommandsConfig,
): CommandRegistryAiActions {
return {
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
aiAgentsStatus: config.aiAgentsStatus,
@@ -214,22 +406,33 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
selectedAiAgent: config.selectedAiAgent,
onCycleDefaultAiAgent: config.onCycleDefaultAiAgent,
selectedAiAgentLabel: config.selectedAiAgentLabel,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
}
}
function createCommandRegistryNoteConfig(
config: AppCommandsConfig,
): CommandRegistryNoteActions {
return {
onSetNoteIcon: config.onSetNoteIcon,
onRemoveNoteIcon: config.onRemoveNoteIcon,
activeNoteHasIcon: config.activeNoteHasIcon,
selection: config.selection,
noteListFilter: config.noteListFilter,
onSetNoteListFilter: config.onSetNoteListFilter,
onOpenInNewWindow: config.onOpenInNewWindow,
onToggleFavorite: config.onToggleFavorite,
onToggleOrganized: config.onToggleOrganized,
onCustomizeNoteListColumns: config.onCustomizeNoteListColumns,
canCustomizeNoteListColumns: config.canCustomizeNoteListColumns,
noteListColumnsLabel: config.noteListColumnsLabel,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
}
}
function createCommandRegistryConfig(config: AppCommandsConfig): CommandRegistryConfig {
return {
...createCommandRegistryCoreConfig(config),
...createCommandRegistrySelectionConfig(config),
...createCommandRegistryVaultConfig(config),
...createCommandRegistryAiConfig(config),
...createCommandRegistryNoteConfig(config),
}
}

View File

@@ -56,6 +56,8 @@ interface CommandRegistryConfig {
onOpenFeedback?: () => 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,

View File

@@ -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)

View File

@@ -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,
})
}

View File

@@ -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<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
async function readRemoteStatus(vaultPath: string): Promise<GitRemoteStatus | null> {
try {
return await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
} catch {
return null
}
}
interface UseStatusBarAddRemoteOptions {
vaultPath: string
isGitVault: boolean
remoteStatus?: GitRemoteStatus | null
onAddRemote?: () => void
}
interface StatusBarAddRemoteState {
openAddRemote: () => Promise<void>
closeAddRemote: () => void
showAddRemote: boolean
visibleRemoteStatus: GitRemoteStatus | null
handleRemoteConnected: (message: string) => Promise<void>
}
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<RemoteStatusOverride | null>(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,
}
}

View File

@@ -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)
})
})

View File

@@ -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<string, boolean> = {
[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<string, (args: any) => 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<string, (args: any) => 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<string, (args: any) => 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 => {

View File

@@ -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 {

View File

@@ -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))
}

View File

@@ -0,0 +1,159 @@
import { test, expect, type Page } from '@playwright/test'
const COMMAND_INPUT = 'input[placeholder="Type a command..."]'
type Handler = (args?: Record<string, unknown>) => unknown
type RemoteArgs = {
request?: {
vaultPath?: string
remoteUrl?: string
}
vaultPath?: string
remoteUrl?: string
}
type BrowserWindow = Window &
typeof globalThis & {
__mockHandlers?: Record<string, Handler>
__remoteConnected?: boolean
__addRemoteCalls?: string[]
}
async function openCommandPalette(page: Page): Promise<void> {
await page.keyboard.press('Control+k')
await expect(page.locator(COMMAND_INPUT)).toBeVisible()
}
async function executeCommand(page: Page, name: string): Promise<void> {
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<void> {
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<string, Handler> | 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<string, Handler> | undefined) ?? null
},
get() {
return applyRemoteMockOverrides(ref) ?? ref
},
})
}
async function installRemoteMocks(page: Page): Promise<void> {
await page.addInitScript(installRemoteMocksScript)
}
async function openAddRemoteFromStatusChip(page: Page): Promise<void> {
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<void> {
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<void> {
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)
})