Compare commits

..
4 Commits
Author SHA1 Message Date
Christoph Brandau f621638eb3 feat(system): enable window destroy capability and stabilize shutdown
publish / Build and publish Ubuntu AppImage (release) Successful in 22m40s
publish / Build and publish Windows installer (release) Successful in 31m3s
publish / Build and publish gitty-desktop to AUR (release) Successful in 50m26s
Adds a new window capability to allow destroying the window.
It also changes shutdown handling in telemetry to avoid errors.

- Add core:window:allow-destroy to default capabilities
- Avoid shutdown errors by returning a pending promise in tracedInvoke
- Improve stability during shutdown for in-flight telemetry calls
2026-08-13 22:11:45 +02:00
Christoph 56467f481d Merge pull request 'Open code test' (#26) from openCodeTest into main
publish / Build and publish Ubuntu AppImage (release) Failing after 2m20s
publish / Build and publish Windows installer (release) Successful in 27m20s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 40m8s
Reviewed-on: #26
2026-08-13 18:48:11 +00:00
Christoph Brandau 6434a9f10c refactor(credentials): remove credential expiry support and API
The credential expiry feature has been removed from both the Rust
backend and the frontend. Stored credentials now include only
username and password.

- Remove expiresAt field from StoredCredential
- Simplify API by removing expiry param from credSave
- Drop expiry UI and expiry checks across the app
2026-08-13 20:45:43 +02:00
Christoph Brandau cc0f1c076f refactor(ui): simplify branch status display and adjust layout
The changes simplify how branch status is shown in HistoryPanel.
The remoteName helper and the check icon were removed.
Status is composed inline with the label for local and remote branches.
CSS tweaks wrap content and adjust widths for better responsiveness.

- Remove remoteName helper and Check icon from HistoryPanel
- Inline status text for local/remote branches and avoid icons
- Improve graph layout: wrap refs and constrain widths on hover
2026-08-13 20:06:27 +02:00
11 changed files with 57 additions and 90 deletions
+1
View File
@@ -9,6 +9,7 @@
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"core:window:allow-unminimize", "core:window:allow-unminimize",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"dialog:allow-open", "dialog:allow-open",
+2 -14
View File
@@ -2432,8 +2432,6 @@ const CRED_SERVICE: &str = "tauri_git_lite";
pub struct StoredCredential { pub struct StoredCredential {
pub username: String, pub username: String,
pub password: String, pub password: String,
#[serde(default, rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
} }
fn cred_entry(key: &str) -> Result<keyring::Entry, String> { fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
@@ -2616,19 +2614,9 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn cred_save( pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> {
key: String,
username: String,
password: String,
expires_at: Option<String>,
) -> Result<(), String> {
let entry = cred_entry(&key)?; let entry = cred_entry(&key)?;
let expires_at = expires_at.filter(|value| !value.trim().is_empty()); let cred = StoredCredential { username, password };
let cred = StoredCredential {
username,
password,
expires_at,
};
let json = serde_json::to_string(&cred) let json = serde_json::to_string(&cred)
.map_err(|err| format!("Could not serialize credentials: {err}"))?; .map_err(|err| format!("Could not serialize credentials: {err}"))?;
entry entry
+6 -20
View File
@@ -180,7 +180,6 @@
import { import {
orgKeyFromUrl, orgKeyFromUrl,
isCredentialExpired,
isAuthError, isAuthError,
stripAuthPrefix, stripAuthPrefix,
summarizeGitError, summarizeGitError,
@@ -2219,11 +2218,10 @@
if (!username && !password) { if (!username && !password) {
const stored = await loadStoredCredential(credentialKey); const stored = await loadStoredCredential(credentialKey);
if (stored && !isCredentialExpired(stored)) { if (stored) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true); await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
return; return;
} }
if (stored && credentialKey) await credDelete(credentialKey).catch(() => {});
} }
operation = "Cloning repository"; operation = "Cloning repository";
@@ -2921,7 +2919,7 @@
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
const key = await currentCredKey(); const key = await currentCredKey();
const stored = await loadStoredCredential(key); const stored = await loadStoredCredential(key);
const credential = stored && !isCredentialExpired(stored) ? stored : null; const credential = stored ?? null;
await runOperation(`Pushing tag ${tag.name}`, async () => { await runOperation(`Pushing tag ${tag.name}`, async () => {
try { try {
@@ -3028,12 +3026,7 @@
async function storedCredentialForNoteRemote(remote: string, direction: "fetch" | "push") { async function storedCredentialForNoteRemote(remote: string, direction: "fetch" | "push") {
const config = commitNoteRemotes.find((item) => item.name === remote); const config = commitNoteRemotes.find((item) => item.name === remote);
const key = orgKeyFromUrl(direction === "push" ? (config?.push_url ?? "") : (config?.fetch_url ?? "")); const key = orgKeyFromUrl(direction === "push" ? (config?.push_url ?? "") : (config?.fetch_url ?? ""));
const stored = await loadStoredCredential(key); return loadStoredCredential(key);
if (stored && isCredentialExpired(stored)) {
if (key) await credDelete(key).catch(() => {});
return null;
}
return stored;
} }
function commitNoteRemoteError(error: unknown): string { function commitNoteRemoteError(error: unknown): string {
@@ -3271,12 +3264,7 @@
handleRemoteResult("push", key, fromStore); handleRemoteResult("push", key, fromStore);
} }
async function handleCredentialSubmit( async function handleCredentialSubmit(username: string, password: string, save: boolean) {
username: string,
password: string,
save: boolean,
expiresAt: string | null,
) {
const key = credDialogKey; const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false); if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false); else if (credDialogAction === "push") await doActualPush(username, password, key, false);
@@ -3296,7 +3284,7 @@
// Only persist once the operation actually succeeded (dialog has closed). // Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) { if (!credDialogOpen && save && key) {
try { try {
await credSave(key, username, password, expiresAt); await credSave(key, username, password);
} catch (error) { } catch (error) {
errorMessage = errorToMessage(error); errorMessage = errorToMessage(error);
} }
@@ -3311,15 +3299,13 @@
const key = await currentCredKey(); const key = await currentCredKey();
const stored = await loadStoredCredential(key); const stored = await loadStoredCredential(key);
if (stored && !isCredentialExpired(stored)) { if (stored) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true); if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true); else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
else await doActualPush(stored.username, stored.password, key, true); else await doActualPush(stored.username, stored.password, key, true);
return; return;
} }
// Expired entry → clean it up before prompting again.
if (stored && key) await credDelete(key).catch(() => {});
await openCredentialDialog(action, key); await openCredentialDialog(action, key);
} }
+26 -2
View File
@@ -2559,8 +2559,10 @@
} }
.branch-ref-cluster { .branch-ref-cluster {
display: inline-flex; display: inline-flex;
flex: 0 1 auto;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
max-width: 100%;
margin-left: -10px; margin-left: -10px;
} }
.compact-ref-chip { .compact-ref-chip {
@@ -2664,7 +2666,8 @@
.graph-row:hover .compact-ref-chip.branch, .graph-row:hover .compact-ref-chip.branch,
.graph-row.selected .compact-ref-chip.branch, .graph-row.selected .compact-ref-chip.branch,
.graph-row:focus-within .compact-ref-chip.branch { .graph-row:focus-within .compact-ref-chip.branch {
max-width: min(62%, 260px); flex: 1 1 auto;
max-width: 100%;
padding-right: 8px; padding-right: 8px;
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 64%, transparent); border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 64%, transparent);
background: background:
@@ -2673,6 +2676,18 @@
inset 2px 0 0 var(--ref-lane-color, #69a7ff), inset 2px 0 0 var(--ref-lane-color, #69a7ff),
0 3px 12px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, rgba(0,0,0,.24)); 0 3px 12px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, rgba(0,0,0,.24));
} }
.graph-row:hover .commit-ref-strip,
.graph-row.selected .commit-ref-strip,
.graph-row:focus-within .commit-ref-strip {
flex-wrap: wrap;
row-gap: 4px;
}
.graph-row:hover .branch-ref-cluster,
.graph-row.selected .branch-ref-cluster,
.graph-row:focus-within .branch-ref-cluster {
flex: 0 0 auto;
max-width: min(100%, 320px);
}
.graph-row:hover .compact-ref-branch-icon, .graph-row:hover .compact-ref-branch-icon,
.graph-row.selected .compact-ref-branch-icon, .graph-row.selected .compact-ref-branch-icon,
.graph-row:focus-within .compact-ref-branch-icon { .graph-row:focus-within .compact-ref-branch-icon {
@@ -3069,8 +3084,17 @@
} }
@media (hover: none) { @media (hover: none) {
.commit-ref-strip {
flex-wrap: wrap;
row-gap: 4px;
}
.branch-ref-cluster {
flex: 0 0 auto;
max-width: min(100%, 320px);
}
.compact-ref-chip.branch { .compact-ref-chip.branch {
max-width: min(62%, 260px); flex: 1 1 auto;
max-width: 100%;
padding-right: 8px; padding-right: 8px;
} }
.compact-ref-chip.branch > span, .compact-ref-chip.branch > span,
+1 -1
View File
@@ -85,7 +85,7 @@
const key = CRED_KEYS[target]; const key = CRED_KEYS[target];
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed) { if (trimmed) {
await credSave(key, "api-key", trimmed, null); await credSave(key, "api-key", trimmed);
} else { } else {
await credDelete(key); await credDelete(key);
} }
+2 -21
View File
@@ -17,7 +17,7 @@
action: "push" | "pull" | "fetch" | "clone"; action: "push" | "pull" | "fetch" | "clone";
error: string; error: string;
isBusy: boolean; isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void; onSubmit: (username: string, password: string, save: boolean) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -36,7 +36,6 @@
let password = $state(""); let password = $state("");
let showPassword = $state(false); let showPassword = $state(false);
let saveSession = $state(true); let saveSession = $state(true);
let expiresAt = $state("");
let canSubmit = $derived( let canSubmit = $derived(
!isBusy && !isBusy &&
@@ -62,12 +61,7 @@
function handleSubmit(e: SubmitEvent) { function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;
onSubmit( onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
mode === "token" ? "oauth2" : username,
password,
saveSession,
saveSession && expiresAt ? expiresAt : null,
);
} }
</script> </script>
@@ -191,19 +185,6 @@
</div> </div>
{/if} {/if}
{#if saveSession}
<div class="cred-expiry">
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
<input
id="cred-expiry"
type="date"
bind:value={expiresAt}
disabled={isBusy}
/>
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
</div>
{/if}
<div class="cred-footer"> <div class="cred-footer">
<label class="cred-save"> <label class="cred-save">
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} /> <input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
+11 -14
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte"; import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types"; import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment { interface GraphSegment {
@@ -544,10 +544,6 @@
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref); return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
} }
function remoteName(branch: string): string {
return branch.split("/", 1)[0] ?? branch;
}
function configuredUpstreamForBranch(local: string): string { function configuredUpstreamForBranch(local: string): string {
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : ""); return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
} }
@@ -649,7 +645,6 @@
} }
function branchStatusLabel(branch: CommitBranchDecoration): string { function branchStatusLabel(branch: CommitBranchDecoration): string {
if (branch.trackedRemote) return `✓ ${remoteName(branch.trackedRemote)}`;
if (branch.label === activeBranch) { if (branch.label === activeBranch) {
const parts = []; const parts = [];
if (activeAhead > 0) parts.push(`↑${activeAhead}`); if (activeAhead > 0) parts.push(`↑${activeAhead}`);
@@ -661,9 +656,14 @@
} }
function branchDecorationTitle(branch: CommitBranchDecoration): string { function branchDecorationTitle(branch: CommitBranchDecoration): string {
if (branch.localOnly) return `${branch.label} · Local only — not published yet`; if (branch.localOnly) {
if (branch.trackedRemote) return `${branch.label} · up to date with ${branch.trackedRemote}`; const status = branchStatusLabel(branch);
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
}
const status = branchStatusLabel(branch); const status = branchStatusLabel(branch);
if (branch.trackedRemote) {
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
}
if (status) return `${branch.label} · ${status}`; if (status) return `${branch.label} · ${status}`;
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`; return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
} }
@@ -865,16 +865,13 @@
> >
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" /> <GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
<span>{refSummary.primaryBranch.label}</span> <span>{refSummary.primaryBranch.label}</span>
{#if branchStatusLabel(refSummary.primaryBranch)} {#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
<small class:up-to-date={Boolean(refSummary.primaryBranch.trackedRemote)}> <small>{branchStatusLabel(refSummary.primaryBranch)}</small>
{#if refSummary.primaryBranch.trackedRemote}<Check size={9} aria-hidden="true" />{/if}
{branchStatusLabel(refSummary.primaryBranch).replace(/^✓\s*/, "")}
</small>
{/if} {/if}
</span> </span>
{#if refSummary.primaryBranch.localOnly} {#if refSummary.primaryBranch.localOnly}
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet"> <span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
<CloudOff size={9} aria-hidden="true" />LOCAL LOCAL
</span> </span>
{/if} {/if}
</span> </span>
-9
View File
@@ -1,5 +1,3 @@
import type { StoredCredential } from "./types";
/** /**
* Derives a credential key from a remote URL, scoped to host + organisation * Derives a credential key from a remote URL, scoped to host + organisation
* the same granularity Azure DevOps / GitHub use. Examples: * the same granularity Azure DevOps / GitHub use. Examples:
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
return org ? `${host}/${org}` : host; return org ? `${host}/${org}` : host;
} }
/** A stored credential is expired only if it carries a past expiry date. */
export function isCredentialExpired(cred: StoredCredential): boolean {
if (!cred.expiresAt) return false;
const time = new Date(cred.expiresAt).getTime();
return !Number.isNaN(time) && time < Date.now();
}
const AUTH_PREFIX = "AUTH_FAILED:"; const AUTH_PREFIX = "AUTH_FAILED:";
export function isAuthError(message: string): boolean { export function isAuthError(message: string): boolean {
+2 -7
View File
@@ -381,13 +381,8 @@ export function credLoad(key: string): Promise<StoredCredential | null> {
return invoke<StoredCredential | null>("cred_load", { key }); return invoke<StoredCredential | null>("cred_load", { key });
} }
export function credSave( export function credSave(key: string, username: string, password: string): Promise<void> {
key: string, return invoke<void>("cred_save", { key, username, password });
username: string,
password: string,
expiresAt: string | null,
): Promise<void> {
return invoke<void>("cred_save", { key, username, password, expiresAt });
} }
export function credDelete(key: string): Promise<void> { export function credDelete(key: string): Promise<void> {
+6 -1
View File
@@ -48,7 +48,12 @@ function randomHex(bytes: number): string {
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> { export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (frontendShuttingDown) { if (frontendShuttingDown) {
throw new Error("Application is shutting down"); // The window is on its way out and nothing calls preventDefault() on the
// close event, so it will finish closing regardless. Never resolving
// avoids surfacing a spurious "shutting down" error in whatever UI catch
// block happens to be awaiting an in-flight operation (e.g. a pull/push
// that makes several sequential calls) when the close request lands.
return new Promise<T>(() => {});
} }
if (!telemetryEnabled) return invoke<T>(command, args); if (!telemetryEnabled) return invoke<T>(command, args);
const startedAtMs = Date.now(); const startedAtMs = Date.now();
-1
View File
@@ -310,5 +310,4 @@ export interface ReflogEntry {
export interface StoredCredential { export interface StoredCredential {
username: string; username: string;
password: string; password: string;
expiresAt?: string | null;
} }