diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 0d7a78e..93b1c11 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -403,6 +403,96 @@ pub fn add_remote(path: &Path, url: &str) -> GitResult { } } +/// Update the URL of the existing 'origin' remote +pub fn set_remote_url(path: &Path, url: &str) -> GitResult { + let normalized = url.trim(); + if !is_valid_remote_url(normalized) { + return GitResult { + success: false, + message: None, + error: Some("Invalid remote URL format. URL must start with https://, http://, or git@".to_string()), + }; + } + + let output = git_cmd() + .args(["remote", "set-url", "origin", normalized]) + .current_dir(path) + .output(); + + match output { + Ok(output) => { + if output.status.success() { + GitResult { + success: true, + message: Some("Remote URL updated".to_string()), + error: None, + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if stderr.contains("No such remote") { + GitResult { + success: false, + message: None, + error: Some("No 'origin' remote configured".to_string()), + } + } else { + GitResult { + success: false, + message: None, + error: Some(stderr.trim().to_string()), + } + } + } + } + Err(e) => GitResult { + success: false, + message: None, + error: Some(format!("Failed to update remote: {}", e)), + }, + } +} + +/// Remove the 'origin' remote +pub fn remove_remote(path: &Path) -> GitResult { + let output = git_cmd() + .args(["remote", "remove", "origin"]) + .current_dir(path) + .output(); + + match output { + Ok(output) => { + if output.status.success() { + GitResult { + success: true, + message: Some("Remote removed".to_string()), + error: None, + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + // Removing an already-missing 'origin' is idempotent — converge on "not connected". + if stderr.contains("No such remote") { + GitResult { + success: true, + message: None, + error: None, + } + } else { + GitResult { + success: false, + message: None, + error: Some(stderr.trim().to_string()), + } + } + } + } + Err(e) => GitResult { + success: false, + message: None, + error: Some(format!("Failed to remove remote: {}", e)), + }, + } +} + /// Push to remote and set upstream tracking (git push -u origin ) pub fn push_with_upstream(path: &Path, branch: &str) -> GitResult { let output = git_cmd() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5c0154..821f461 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2673,6 +2673,52 @@ async fn git_add_remote(url: String, state: State<'_, AppState>) -> Result) -> Result { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config.notes_folder.clone() + }; + + match folder { + Some(path) => { + tauri::async_runtime::spawn_blocking(move || { + git::set_remote_url(&PathBuf::from(path), &url) + }) + .await + .map_err(|e| e.to_string()) + } + None => Ok(git::GitResult { + success: false, + message: None, + error: Some("Notes folder not set".to_string()), + }), + } +} + +#[tauri::command] +async fn git_remove_remote(state: State<'_, AppState>) -> Result { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config.notes_folder.clone() + }; + + match folder { + Some(path) => { + tauri::async_runtime::spawn_blocking(move || { + git::remove_remote(&PathBuf::from(path)) + }) + .await + .map_err(|e| e.to_string()) + } + None => Ok(git::GitResult { + success: false, + message: None, + error: Some("Notes folder not set".to_string()), + }), + } +} + #[tauri::command] async fn git_push_with_upstream(state: State<'_, AppState>) -> Result { let folder = { @@ -3797,6 +3843,8 @@ pub fn run() { git_fetch, git_pull, git_add_remote, + git_set_remote_url, + git_remove_remote, git_push_with_upstream, ai_check_claude_cli, ai_check_codex_cli, diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index de996f7..8faac35 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -110,8 +110,8 @@ export const Footer = memo(function Footer({ onOpenSettings }: FooterProps) { ) : null} - {/* Changes indicator */} - {hasChanges && ( + {/* Changes indicator — hidden when there's an error so we don't show a stale count alongside it */} + {hasChanges && !lastError && ( Files changed @@ -123,7 +123,7 @@ export const Footer = memo(function Footer({ onOpenSettings }: FooterProps) { diff --git a/src/components/settings/GeneralSettingsSection.tsx b/src/components/settings/GeneralSettingsSection.tsx index c77d9ff..320657f 100644 --- a/src/components/settings/GeneralSettingsSection.tsx +++ b/src/components/settings/GeneralSettingsSection.tsx @@ -56,6 +56,8 @@ export function GeneralSettingsSection() { initRepo, isLoading, addRemote, + setRemoteUrl: updateRemoteUrl, + removeRemote, pushWithUpstream, isAddingRemote, isPushing, @@ -65,6 +67,7 @@ export function GeneralSettingsSection() { const [remoteUrl, setRemoteUrl] = useState(""); const [showRemoteInput, setShowRemoteInput] = useState(false); + const [isEditingRemote, setIsEditingRemote] = useState(false); const [noteTemplate, setNoteTemplate] = useState("Untitled"); const [previewNoteName, setPreviewNoteName] = useState("Untitled"); // Load template from settings on mount @@ -177,6 +180,42 @@ export function GeneralSettingsSection() { } }; + const handleStartEditRemote = () => { + setRemoteUrl(status?.remoteUrl || ""); + setIsEditingRemote(true); + clearError(); + }; + + const handleCancelEditRemote = () => { + setIsEditingRemote(false); + setRemoteUrl(""); + clearError(); + }; + + const handleSaveRemoteUrl = async () => { + if (isAddingRemote) return; + const trimmed = remoteUrl.trim(); + if (!trimmed) return; + if (trimmed === status?.remoteUrl) { + setIsEditingRemote(false); + return; + } + const success = await updateRemoteUrl(trimmed); + if (success) { + setRemoteUrl(""); + setIsEditingRemote(false); + } + }; + + const handleRemoveRemote = async () => { + if (isAddingRemote) return; + const success = await removeRemote(); + if (success) { + setRemoteUrl(""); + setIsEditingRemote(false); + } + }; + const handlePushWithUpstream = async () => { await pushWithUpstream(); }; @@ -198,6 +237,7 @@ export function GeneralSettingsSection() { if (!enabled) { setShowRemoteInput(false); + setIsEditingRemote(false); setRemoteUrl(""); } }; @@ -345,32 +385,98 @@ export function GeneralSettingsSection() { {/* Remote configuration */} {status.hasRemote ? ( <> -
- - Remote - - {getRemoteWebUrl(status.remoteUrl) ? ( - - ) : ( - - {formatRemoteUrl(status.remoteUrl)} + {isEditingRemote ? ( +
+ + Remote - )} -
+ setRemoteUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveRemoteUrl(); + if (e.key === "Escape") handleCancelEditRemote(); + }} + placeholder="https://github.com/user/repo.git" + autoFocus + /> +
+ + + +
+ +
+ ) : ( +
+ + Remote + +
+ {getRemoteWebUrl(status.remoteUrl) ? ( + + ) : ( + + {formatRemoteUrl(status.remoteUrl)} + + )} + +
+
+ )} {/* Upstream tracking status */} {status.hasUpstream ? ( @@ -422,7 +528,7 @@ export function GeneralSettingsSection() { Remote - + Not connected @@ -481,57 +587,68 @@ export function GeneralSettingsSection() { )} - {/* Changes count */} - {status.changedCount > 0 && ( + {/* Stats — hidden whenever there's an error, since counts may be stale or misleading alongside it */} + {lastError ? (
- - Changes to commit - + Status - {status.changedCount} file - {status.changedCount === 1 ? "" : "s"} changed + An error occurred
- )} + ) : ( + <> + {status.changedCount > 0 && ( +
+ + Changes to commit + + + {status.changedCount} file + {status.changedCount === 1 ? "" : "s"} changed + +
+ )} - {/* Commits to push */} - {status.aheadCount > 0 && status.hasUpstream && ( -
- - Commits to push - - - {status.aheadCount} commit - {status.aheadCount === 1 ? "" : "s"} - -
- )} + {status.aheadCount > 0 && status.hasUpstream && ( +
+ + Commits to push + + + {status.aheadCount} commit + {status.aheadCount === 1 ? "" : "s"} + +
+ )} - {/* Commits to pull */} - {status.behindCount > 0 && status.hasUpstream && ( -
- - Commits to pull - - - {status.behindCount} commit - {status.behindCount === 1 ? "" : "s"} - -
+ {status.behindCount > 0 && status.hasUpstream && ( +
+ + Commits to pull + + + {status.behindCount} commit + {status.behindCount === 1 ? "" : "s"} + +
+ )} + )} {/* Error display */} {lastError && (
-
-

{lastError}

+
+

+ {lastError} +

{(lastError.includes("Authentication") || lastError.includes("SSH")) && ( Learn more about SSH authentication @@ -539,7 +656,7 @@ export function GeneralSettingsSection() { @@ -736,7 +853,9 @@ function IgnoredFoldersEditor() { try { await invoke("rebuild_search_index"); } catch { - toast.error("Search index rebuild failed — search results may be stale"); + toast.error( + "Search index rebuild failed — search results may be stale", + ); } } catch { toast.error("Failed to save ignored folders"); diff --git a/src/context/GitContext.tsx b/src/context/GitContext.tsx index c01c261..11432ae 100644 --- a/src/context/GitContext.tsx +++ b/src/context/GitContext.tsx @@ -37,6 +37,8 @@ interface GitContextValue { pull: () => Promise; sync: () => Promise<{ ok: true; message: string } | { ok: false; error: string }>; addRemote: (url: string) => Promise; + setRemoteUrl: (url: string) => Promise; + removeRemote: () => Promise; pushWithUpstream: () => Promise; clearError: () => void; } @@ -273,6 +275,42 @@ export function GitProvider({ children }: { children: ReactNode }) { } }, [refreshStatus]); + const setRemoteUrl = useCallback(async (url: string) => { + setIsAddingRemote(true); + try { + const result = await gitService.setRemoteUrl(url); + if (result.error) { + setLastError(result.error); + return false; + } + await refreshStatus(); + return true; + } catch (err) { + setLastError(err instanceof Error ? err.message : "Failed to update remote"); + return false; + } finally { + setIsAddingRemote(false); + } + }, [refreshStatus]); + + const removeRemote = useCallback(async () => { + setIsAddingRemote(true); + try { + const result = await gitService.removeRemote(); + if (result.error) { + setLastError(result.error); + return false; + } + await refreshStatus(); + return true; + } catch (err) { + setLastError(err instanceof Error ? err.message : "Failed to remove remote"); + return false; + } finally { + setIsAddingRemote(false); + } + }, [refreshStatus]); + const pushWithUpstream = useCallback(async () => { setIsPushing(true); try { @@ -439,6 +477,8 @@ export function GitProvider({ children }: { children: ReactNode }) { pull, sync, addRemote, + setRemoteUrl, + removeRemote, pushWithUpstream, clearError, }), @@ -462,6 +502,8 @@ export function GitProvider({ children }: { children: ReactNode }) { pull, sync, addRemote, + setRemoteUrl, + removeRemote, pushWithUpstream, clearError, ] diff --git a/src/services/git.ts b/src/services/git.ts index 74a7844..f144ebe 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -50,6 +50,14 @@ export async function addRemote(url: string): Promise { return invoke("git_add_remote", { url }); } +export async function setRemoteUrl(url: string): Promise { + return invoke("git_set_remote_url", { url }); +} + +export async function removeRemote(): Promise { + return invoke("git_remove_remote"); +} + export async function pushWithUpstream(): Promise { return invoke("git_push_with_upstream"); }