feat: keep getting started vaults local by default
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
27
src-tauri/src/commands/git_connect.rs
Normal file
27
src-tauri/src/commands/git_connect.rs
Normal 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())
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
577
src-tauri/src/git/connect.rs
Normal file
577
src-tauri/src/git/connect.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
96
src/components/AddRemoteModal.test.tsx
Normal file
96
src/components/AddRemoteModal.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
171
src/components/AddRemoteModal.tsx
Normal file
171
src/components/AddRemoteModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.(),
|
||||
|
||||
@@ -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' }) },
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
107
src/hooks/useStatusBarAddRemote.ts
Normal file
107
src/hooks/useStatusBarAddRemote.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
33
src/mock-tauri/mock-handlers.test.ts
Normal file
33
src/mock-tauri/mock-handlers.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
5
src/utils/addRemoteEvents.ts
Normal file
5
src/utils/addRemoteEvents.ts
Normal 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))
|
||||
}
|
||||
159
tests/smoke/add-remote-from-no-remote.spec.ts
Normal file
159
tests/smoke/add-remote-from-no-remote.spec.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user