From 8f3473c67cdcc1af7caced136bab3a0519507f75 Mon Sep 17 00:00:00 2001 From: erictli Date: Mon, 4 May 2026 21:16:00 -0400 Subject: [PATCH 1/6] feat: allow changing or removing the git remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Change/Remove controls next to the configured remote in Version Control settings, backed by new git_set_remote_url and git_remove_remote Tauri commands. Resolves #147 — users who added the wrong remote URL can now fix it without losing local history. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/git.rs | 88 ++++++++++ src-tauri/src/lib.rs | 48 ++++++ .../settings/GeneralSettingsSection.tsx | 156 +++++++++++++++--- src/context/GitContext.tsx | 42 +++++ src/services/git.ts | 8 + 5 files changed, 317 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 0d7a78e9..fab6c39b 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -403,6 +403,94 @@ 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 { + if !is_valid_remote_url(url) { + 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", url]) + .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(); + 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 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 f5c0154e..821f4618 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/settings/GeneralSettingsSection.tsx b/src/components/settings/GeneralSettingsSection.tsx index c77d9ffe..d4deb440 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 ? ( diff --git a/src/context/GitContext.tsx b/src/context/GitContext.tsx index c01c261c..11432ae8 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 74a78441..f144ebe9 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"); } From a84cc779318ce828e329bd38179104c7e8739385 Mon Sep 17 00:00:00 2001 From: erictli Date: Mon, 4 May 2026 21:24:11 -0400 Subject: [PATCH 2/6] fix: hide stale stats when git status fetch errored Replaces the changes/commits-to-push/commits-to-pull rows with a single "An error occurred" row when status.error is set, so we don't display counts that may be incorrect alongside the error message. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/GeneralSettingsSection.tsx | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/src/components/settings/GeneralSettingsSection.tsx b/src/components/settings/GeneralSettingsSection.tsx index d4deb440..0f4943b1 100644 --- a/src/components/settings/GeneralSettingsSection.tsx +++ b/src/components/settings/GeneralSettingsSection.tsx @@ -587,43 +587,52 @@ export function GeneralSettingsSection() { )} - {/* Changes count */} - {status.changedCount > 0 && ( + {/* Stats — hidden when status fetch errored, since counts may be stale or wrong */} + {status.error ? (
- - 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 */} From 447d04439d7e7e309dfb52d10c84db3ef0d0cc67 Mon Sep 17 00:00:00 2001 From: erictli Date: Mon, 4 May 2026 21:26:50 -0400 Subject: [PATCH 3/6] fix: simpler Change link and gate stats on lastError - Use a plain text button for Change, matching the Install link in integrations, instead of a ghost-variant Button. - Hide stats whenever lastError is set (not just status.error), so action errors like a failed push also collapse the stale counts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/GeneralSettingsSection.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/settings/GeneralSettingsSection.tsx b/src/components/settings/GeneralSettingsSection.tsx index 0f4943b1..ea2537f3 100644 --- a/src/components/settings/GeneralSettingsSection.tsx +++ b/src/components/settings/GeneralSettingsSection.tsx @@ -467,13 +467,13 @@ export function GeneralSettingsSection() { {formatRemoteUrl(status.remoteUrl)} )} - + )} @@ -587,8 +587,8 @@ export function GeneralSettingsSection() { )} - {/* Stats — hidden when status fetch errored, since counts may be stale or wrong */} - {status.error ? ( + {/* Stats — hidden whenever there's an error, since counts may be stale or misleading alongside it */} + {lastError ? (
Status @@ -646,7 +646,7 @@ export function GeneralSettingsSection() { href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh" target="_blank" rel="noopener noreferrer" - className="text-xs text-red-400 hover:text-red-300 underline mt-1 inline-block" + className="text-xs text-red-500 hover:text-red-700 underline mt-1 inline-block" > Learn more about SSH authentication @@ -654,7 +654,7 @@ export function GeneralSettingsSection() { @@ -851,7 +851,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"); From 7dd631ec69ae6f46651b8866cc705d0ec0621fb3 Mon Sep 17 00:00:00 2001 From: erictli Date: Mon, 4 May 2026 21:27:46 -0400 Subject: [PATCH 4/6] fix: hide footer 'Files changed' when an error is shown The footer was rendering both 'Files changed' and 'An error occurred' side-by-side. Gate the changes indicator on !lastError so the count isn't shown alongside an error message. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/layout/Footer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index de996f71..dbe67246 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 From e4da975815f2e6b67257867c76171cde2f4f705e Mon Sep 17 00:00:00 2001 From: erictli Date: Mon, 4 May 2026 21:36:37 -0400 Subject: [PATCH 5/6] =?UTF-8?q?style:=20align=20error=20styling=20?= =?UTF-8?q?=E2=80=94=20red=20instead=20of=20orange,=20larger=20link=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/layout/Footer.tsx | 2 +- src/components/settings/GeneralSettingsSection.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index dbe67246..8faac354 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -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 ea2537f3..620e86d0 100644 --- a/src/components/settings/GeneralSettingsSection.tsx +++ b/src/components/settings/GeneralSettingsSection.tsx @@ -433,7 +433,7 @@ export function GeneralSettingsSection() { size="sm" onClick={handleRemoveRemote} disabled={isAddingRemote} - className="ml-auto text-red-500 hover:text-red-400 hover:bg-red-500/10" + className="ml-auto text-red-500 hover:text-red-600 hover:bg-red-500/10" > Remove @@ -528,7 +528,7 @@ export function GeneralSettingsSection() { Remote - + Not connected
@@ -638,15 +638,17 @@ export function GeneralSettingsSection() { {/* Error display */} {lastError && (
-
-

{lastError}

+
+

+ {lastError} +

{(lastError.includes("Authentication") || lastError.includes("SSH")) && ( Learn more about SSH authentication @@ -654,7 +656,7 @@ export function GeneralSettingsSection() { From c332f11e664967a6a086d29ce2971790f383fe2c Mon Sep 17 00:00:00 2001 From: erictli Date: Mon, 4 May 2026 21:40:41 -0400 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20review=20feedback=20=E2=80=94=20idem?= =?UTF-8?q?potent=20remove,=20normalized=20url,=20scoped=20capitalize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove_remote: treat 'No such remote' as success so removing an already-missing origin converges to 'not connected' on the frontend. - set_remote_url: trim+normalize the incoming URL once and use the normalized value for both validation and the git invocation, so whitespace-padded URLs aren't written to git config. - Settings error block: switch capitalize → first-letter:capitalize so only the first character is uppercased (preserves casing in URLs, hashes, branch names). Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/git.rs | 10 ++++++---- src/components/settings/GeneralSettingsSection.tsx | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index fab6c39b..93b1c11d 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -405,7 +405,8 @@ 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 { - if !is_valid_remote_url(url) { + let normalized = url.trim(); + if !is_valid_remote_url(normalized) { return GitResult { success: false, message: None, @@ -414,7 +415,7 @@ pub fn set_remote_url(path: &Path, url: &str) -> GitResult { } let output = git_cmd() - .args(["remote", "set-url", "origin", url]) + .args(["remote", "set-url", "origin", normalized]) .current_dir(path) .output(); @@ -468,11 +469,12 @@ pub fn remove_remote(path: &Path) -> GitResult { } } 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: false, + success: true, message: None, - error: Some("No 'origin' remote configured".to_string()), + error: None, } } else { GitResult { diff --git a/src/components/settings/GeneralSettingsSection.tsx b/src/components/settings/GeneralSettingsSection.tsx index 620e86d0..320657fa 100644 --- a/src/components/settings/GeneralSettingsSection.tsx +++ b/src/components/settings/GeneralSettingsSection.tsx @@ -639,7 +639,7 @@ export function GeneralSettingsSection() { {lastError && (
-

+

{lastError}

{(lastError.includes("Authentication") ||