Compare commits

...
5 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
Christoph Brandau b6b9964a93 feat(git): add upstream tracking and local-only branch UI
This release adds upstream tracking for branches and a local-only
indicator in the UI.
The Git integration now exposes upstreams and supports publish flows.
This enables publishing and remote-tracking configuration.

- Introduces upstream tracking and local-only branch UI markers
- Updates toolbar to reflect publish-local state and status indicators
- Bumps version to 2026.8.3 and updates changelog
2026-08-13 19:32:26 +02:00
17 changed files with 467 additions and 125 deletions
+79 -3
View File
@@ -2,9 +2,82 @@
All notable user-facing changes to Gitty are documented in this file.
The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
package metadata uses the equivalent numeric form without leading zeroes where
required by the package manager.
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
## [2026.8.3] - 2026-08-13
### Added
- Configurable external tools for editors, diff viewers, merge tools,
terminals, and file managers, including automatic cross-platform discovery
and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common
applications.
- Repository and file actions for opening content in the configured external
application. Supported tools open in a separate window.
- A choice between Gitty's internal diff/merge views and the configured
external applications.
- Git Notes support for attaching editable notes to commits without rewriting
commit history, including fetch and push synchronization.
- A command palette for quickly opening repository actions, files, and commits.
- Complete branch-to-branch comparisons for local and remote branches. The
comparison dialog shows every changed file and its side-by-side diff.
- Safe remote branch renaming from the branch context menu.
### Changed
- Redesigned the settings window with tool categories, detected applications,
preset dropdowns, and clearer explanations of where each tool is used.
- Redesigned the history graph's branch presentation with compact labels,
hover details, cleaner flag connectors, and branch visibility controls.
- Reduced the minimum width of the commit history panel so the workspace can
be resized more freely.
- Local-only branches are now identified consistently in the toolbar,
repository summary, status bar, and commit graph. Their first push is labeled
Publish and configures the remote tracking branch automatically.
- Git operations now run asynchronously to keep the application responsive
during slower repository commands.
### Fixed
- Closing supported external tools no longer reports their documented
comparison result codes as application errors.
- External tools that otherwise reuse an existing process are explicitly
opened in a new window where supported.
- Remote branch renaming uses an atomic push with lease checks, preventing an
existing destination branch or a newly changed remote branch from being
overwritten.
## [2026.8.2] - 2026-08-10
### Changed
- History graph colors remain stable across parent lanes, making longer and
branching histories easier to follow.
- Release artifacts are published to the matching Gitea release automatically
without creating duplicate assets.
- Application shutdown now completes telemetry cleanup more reliably.
## [2026.8.1] - 2026-08-04
### Added
- Paginated commit history that loads older commits on demand instead of
limiting the visible repository history to the initial page.
- A dedicated file-history dialog opened from the explorer context menu.
- Windows and Ubuntu release publishing plus improved AUR packaging workflows.
### Changed
- File history moved out of the permanent workspace panel into a focused,
larger dialog.
- Dialogs close more consistently with the Escape key.
- Arch Linux installation documentation now uses the `gitty-desktop` AUR
package.
### Fixed
- AUR SSH setup, package installation timeouts, and clone/push retries are more
robust in the release workflow.
## [2026.07.22] - 2026-07-22
@@ -88,3 +161,6 @@ required by the package manager.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2
[2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.8.2",
"version": "2026.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.8.2",
"version": "2026.8.3",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.8.2",
"version": "2026.8.3",
"private": true,
"type": "module",
"scripts": {
+1
View File
@@ -9,6 +9,7 @@
"core:window:allow-toggle-maximize",
"core:window:allow-unminimize",
"core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"dialog:allow-open",
+63 -18
View File
@@ -66,6 +66,7 @@ pub struct GitBranch {
pub name: String,
pub current: bool,
pub remote: bool,
pub upstream: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -842,7 +843,7 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
repo,
[
"for-each-ref",
"--format=%(refname)\t%(HEAD)",
"--format=%(refname)\t%(HEAD)\t%(upstream:short)",
"refs/heads",
"refs/remotes",
],
@@ -851,9 +852,10 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let mut branches = Vec::new();
for line in text.lines() {
let Some((ref_name, head_marker)) = line.split_once('\t') else {
continue;
};
let mut parts = line.splitn(3, '\t');
let ref_name = parts.next().unwrap_or_default();
let head_marker = parts.next().unwrap_or_default();
let configured_upstream = parts.next().unwrap_or_default().trim();
let (name, remote) = if let Some(name) = ref_name.strip_prefix("refs/heads/") {
(name, false)
@@ -870,6 +872,11 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
name: name.to_string(),
current: head_marker.trim() == "*",
remote,
upstream: if remote || configured_upstream.is_empty() {
None
} else {
Some(configured_upstream.to_string())
},
});
}
@@ -2425,8 +2432,6 @@ const CRED_SERVICE: &str = "tauri_git_lite";
pub struct StoredCredential {
pub username: 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> {
@@ -2609,19 +2614,9 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
}
#[tauri::command(async)]
pub fn cred_save(
key: String,
username: String,
password: String,
expires_at: Option<String>,
) -> Result<(), String> {
pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> {
let entry = cred_entry(&key)?;
let expires_at = expires_at.filter(|value| !value.trim().is_empty());
let cred = StoredCredential {
username,
password,
expires_at,
};
let cred = StoredCredential { username, password };
let json = serde_json::to_string(&cred)
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
entry
@@ -6240,6 +6235,56 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]);
}
#[test]
fn branches_report_configured_upstream_and_local_only_state() {
let repo = init_temp_repo("branch_upstream_state");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "feature/local-only"]);
run_git_test(&repo.path, ["branch", "feature/tracked"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
run_git_test(
&repo.path,
[
"update-ref",
"refs/remotes/origin/feature/published",
"HEAD",
],
);
run_git_test(
&repo.path,
["config", "branch.feature/tracked.remote", "origin"],
);
run_git_test(
&repo.path,
[
"config",
"branch.feature/tracked.merge",
"refs/heads/feature/published",
],
);
let branches = branches_for_repo(&repo.path).expect("branches should load");
let local_only = branches
.iter()
.find(|branch| branch.name == "feature/local-only")
.expect("local-only branch should exist");
let tracked = branches
.iter()
.find(|branch| branch.name == "feature/tracked")
.expect("tracked branch should exist");
let remote = branches
.iter()
.find(|branch| branch.name == "origin/feature/published")
.expect("remote branch should exist");
assert_eq!(local_only.upstream, None);
assert_eq!(
tracked.upstream.as_deref(),
Some("origin/feature/published")
);
assert_eq!(remote.upstream, None);
}
#[test]
fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() {
let repo = init_temp_repo("commit_notes_crud");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.8.2",
"version": "2026.8.3",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+37 -26
View File
@@ -4,7 +4,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
import { beginFrontendShutdown } from "./lib/telemetry";
import TitleBar from "./lib/TitleBar.svelte";
@@ -180,7 +180,6 @@
import {
orgKeyFromUrl,
isCredentialExpired,
isAuthError,
stripAuthPrefix,
summarizeGitError,
@@ -506,7 +505,11 @@
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
$: localBranches = branches.filter((b) => !b.remote);
$: localBranchNames = localBranches.map((b) => b.name);
$: localBranchUpstreams = Object.fromEntries(
localBranches.flatMap((branch) => branch.upstream ? [[branch.name, branch.upstream]] : []),
) as Record<string, string>;
$: remoteBranches = branches.filter((b) => b.remote);
$: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
$: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: recentRepoRows = recentRepoPaths
@@ -2215,11 +2218,10 @@
if (!username && !password) {
const stored = await loadStoredCredential(credentialKey);
if (stored && !isCredentialExpired(stored)) {
if (stored) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
return;
}
if (stored && credentialKey) await credDelete(credentialKey).catch(() => {});
}
operation = "Cloning repository";
@@ -2917,7 +2919,7 @@
if (!activeRepoPath || isBusy) return;
const key = await currentCredKey();
const stored = await loadStoredCredential(key);
const credential = stored && !isCredentialExpired(stored) ? stored : null;
const credential = stored ?? null;
await runOperation(`Pushing tag ${tag.name}`, async () => {
try {
@@ -3024,12 +3026,7 @@
async function storedCredentialForNoteRemote(remote: string, direction: "fetch" | "push") {
const config = commitNoteRemotes.find((item) => item.name === remote);
const key = orgKeyFromUrl(direction === "push" ? (config?.push_url ?? "") : (config?.fetch_url ?? ""));
const stored = await loadStoredCredential(key);
if (stored && isCredentialExpired(stored)) {
if (key) await credDelete(key).catch(() => {});
return null;
}
return stored;
return loadStoredCredential(key);
}
function commitNoteRemoteError(error: unknown): string {
@@ -3267,12 +3264,7 @@
handleRemoteResult("push", key, fromStore);
}
async function handleCredentialSubmit(
username: string,
password: string,
save: boolean,
expiresAt: string | null,
) {
async function handleCredentialSubmit(username: string, password: string, save: boolean) {
const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
@@ -3292,7 +3284,7 @@
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
try {
await credSave(key, username, password, expiresAt);
await credSave(key, username, password);
} catch (error) {
errorMessage = errorToMessage(error);
}
@@ -3307,15 +3299,13 @@
const key = await currentCredKey();
const stored = await loadStoredCredential(key);
if (stored && !isCredentialExpired(stored)) {
if (stored) {
if (action === "pull") await doActualPull(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);
return;
}
// Expired entry → clean it up before prompting again.
if (stored && key) await credDelete(key).catch(() => {});
await openCredentialDialog(action, key);
}
@@ -4445,6 +4435,7 @@
{operation}
ahead={status?.ahead ?? 0}
behind={status?.behind ?? 0}
localOnly={currentBranchIsLocalOnly}
language={appLanguage}
editorName={editorToolName}
terminalName={terminalToolName}
@@ -4896,9 +4887,22 @@
{/if}
</div>
<div class="sync-stats" aria-label="Sync state">
{#if status?.upstream}<span title="Upstream">{status.upstream}</span>{/if}
<strong>{status?.ahead ?? 0} ahead</strong>
<strong>{status?.behind ?? 0} behind</strong>
{#if currentBranchIsLocalOnly}
<span
class="sync-local-only"
title={appLanguage === "de"
? "Dieser Branch existiert nur auf diesem Computer. Veröffentlichen richtet den Remote-Branch ein."
: "This branch exists only on this computer. Publish configures its remote branch."}
>
<CloudOff size={11} aria-hidden="true" />
<strong>{appLanguage === "de" ? "Nur lokal" : "Local only"}</strong>
<small>{appLanguage === "de" ? "noch nicht veröffentlicht" : "not published yet"}</small>
</span>
{:else}
{#if status?.upstream}<span title="Upstream">{status.upstream}</span>{/if}
<strong>{status?.ahead ?? 0} ahead</strong>
<strong>{status?.behind ?? 0} behind</strong>
{/if}
</div>
</div>
@@ -4991,6 +4995,7 @@
{commits}
{selectedCommitHash}
{localBranchNames}
{localBranchUpstreams}
remoteBranchNames={remoteBranches.map((branch) => branch.name)}
activeBranch={status?.current_branch ?? ""}
activeUpstream={status?.upstream ?? ""}
@@ -5027,8 +5032,14 @@
<span class="workspace-status-spacer"></span>
{#if workspaceActive}
<span class="workspace-branch"><GitBranch size={12} aria-hidden="true" />{status?.current_branch ?? "No branch"}</span>
<span class="ahead">↑ {status?.ahead ?? 0}</span>
<span class="behind">↓ {status?.behind ?? 0}</span>
{#if currentBranchIsLocalOnly}
<span class="workspace-local-only" title={appLanguage === "de" ? "Branch noch nicht veröffentlicht" : "Branch not published yet"}>
<CloudOff size={11} aria-hidden="true" />{appLanguage === "de" ? "Nur lokal" : "Local only"}
</span>
{:else}
<span class="ahead">↑ {status?.ahead ?? 0}</span>
<span class="behind">↓ {status?.behind ?? 0}</span>
{/if}
<span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
{/if}
{#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
+137 -3
View File
@@ -736,6 +736,29 @@
}
.repo-action-count.behind { color: #7aacff; }
.repo-action-count.ahead { color: #e0a040; }
.repo-action.sync-primary.publish-local {
color: #f0bd6b;
background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045));
}
.repo-action.sync-primary.publish-local:hover:not(:disabled) {
color: #ffd48c;
background: rgba(224,160,64,.14);
}
.repo-action-local-marker {
display: inline-flex;
align-items: center;
gap: 3px;
height: 16px;
padding: 0 4px;
border: 1px dashed rgba(240,189,107,.5);
border-radius: 4px;
color: #f0bd6b;
background: rgba(224,160,64,.08);
font-family: var(--font-mono);
font-size: 7.5px;
font-weight: 900;
letter-spacing: .04em;
}
.repo-toolbar-divider {
width: 1px;
height: 30px;
@@ -1670,6 +1693,27 @@
.sync-stats strong:first-of-type { color: #e0a040; background: rgba(224,160,64,0.13); }
.sync-stats strong:last-of-type { color: #7aacff; background: rgba(122,172,255,0.13); }
.sync-stats span { color: var(--color-ink-dim); background: rgba(94,110,156,0.13); }
.sync-stats .sync-local-only {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 7px;
border: 1px dashed rgba(224,160,64,.38);
color: #f0bd6b;
background: rgba(224,160,64,.09);
}
.sync-stats .sync-local-only strong {
padding: 0;
color: #f0bd6b;
background: transparent;
font-size: 10.5px;
font-weight: 850;
}
.sync-stats .sync-local-only small {
color: var(--color-ink-faint);
font-size: 9px;
font-weight: 650;
}
.top-section {
display: grid;
@@ -2513,6 +2557,14 @@
min-width: 0;
min-height: 20px;
}
.branch-ref-cluster {
display: inline-flex;
flex: 0 1 auto;
align-items: center;
min-width: 0;
max-width: 100%;
margin-left: -10px;
}
.compact-ref-chip {
display: inline-flex;
align-items: center;
@@ -2536,7 +2588,7 @@
flex: 0 1 auto;
max-width: 22px;
height: 20px;
margin-left: -10px;
margin-left: 0;
padding: 0 5px;
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 34%, transparent);
border-left-width: 2px;
@@ -2552,6 +2604,29 @@
background 140ms ease,
box-shadow 140ms ease;
}
.branch-ref-cluster.local-only .compact-ref-chip.branch {
border-radius: 0 !important;
}
.compact-ref-local-marker {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 3px;
height: 20px;
margin-left: -1px;
padding: 0 5px 0 4px;
border: 1px dashed rgba(240,189,107,.58);
border-left-style: solid;
border-radius: 0 5px 5px 0;
color: #f0bd6b;
background: linear-gradient(90deg, rgba(224,160,64,.13), rgba(224,160,64,.06));
font-family: var(--font-mono);
font-size: 7.5px;
font-weight: 900;
letter-spacing: .05em;
line-height: 1;
box-shadow: inset 1px 0 0 rgba(240,189,107,.2);
}
.compact-ref-branch-icon {
flex: 0 0 auto;
color: var(--ref-lane-color, #69a7ff);
@@ -2591,7 +2666,8 @@
.graph-row:hover .compact-ref-chip.branch,
.graph-row.selected .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;
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 64%, transparent);
background:
@@ -2600,6 +2676,18 @@
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));
}
.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.selected .compact-ref-branch-icon,
.graph-row:focus-within .compact-ref-branch-icon {
@@ -2713,6 +2801,12 @@
font-size: 8px;
font-weight: 800;
}
.commit-ref-detail-item small.local-only {
display: inline-flex;
align-items: center;
gap: 3px;
color: #d99532;
}
.commit-files {
display: grid;
@@ -2990,8 +3084,17 @@
}
@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 {
max-width: min(62%, 260px);
flex: 1 1 auto;
max-width: 100%;
padding-right: 8px;
}
.compact-ref-chip.branch > span,
@@ -5905,6 +6008,17 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.workspace-health.clean > span { background: #2da44e; }
.workspace-statusbar .ahead { color: #d9891b; }
.workspace-statusbar .behind { color: var(--color-primary); }
.workspace-statusbar .workspace-local-only {
padding: 2px 6px;
border: 1px dashed rgba(224,160,64,.42);
border-radius: 4px;
color: #f0bd6b;
background: rgba(224,160,64,.08);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 850;
letter-spacing: .02em;
}
.workspace-auto i { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ink-faint); }
.workspace-auto.active i { background: #2da44e; }
.app-version {
@@ -6140,6 +6254,13 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 14%, #f7faff), #f7faff);
}
:root[data-theme="light"] .compact-ref-local-marker {
border-color: rgba(154,82,0,.46);
color: #8b5207;
background: linear-gradient(90deg, rgba(217,137,27,.14), rgba(217,137,27,.06));
box-shadow: inset 1px 0 0 rgba(154,82,0,.14);
}
:root[data-theme="light"] .compact-ref-chip.current {
border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 58%, rgba(49,95,214,.2));
color: #18345f;
@@ -6709,6 +6830,19 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
color: #0755c8;
font-weight: 800;
}
:root[data-theme="light"] .repo-action.sync-primary.publish-local,
:root[data-theme="light"] .repo-action-local-marker,
:root[data-theme="light"] .workspace-statusbar .workspace-local-only {
color: #8b5207;
border-color: rgba(154,82,0,.4);
background: rgba(217,137,27,.09);
}
:root[data-theme="light"] .sync-stats .sync-local-only {
border-color: rgba(154,82,0,.36);
color: #8b5207;
background: rgba(217,137,27,.09);
}
:root[data-theme="light"] .sync-stats .sync-local-only strong { color: #8b5207; }
/* AI pre-commit review --------------------------------------------------- */
.commit-review-button {
+19 -4
View File
@@ -3,6 +3,7 @@
ChevronDown,
Code2,
CloudDownload,
CloudOff,
Download,
FolderOpen,
GitCompare,
@@ -21,6 +22,7 @@
export let operation: string = "";
export let ahead: number = 0;
export let behind: number = 0;
export let localOnly: boolean = false;
export let language: "en" | "de" = "en";
export let editorName: string = "Editor";
export let terminalName: string = "Terminal";
@@ -45,6 +47,12 @@
let toolbarElement: HTMLDivElement;
$: isGerman = language === "de";
$: pushLabel = localOnly ? (isGerman ? "Veröffentlichen" : "Publish") : "Push";
$: pushTitle = localOnly
? (isGerman
? "Dieser Branch existiert nur lokal. Veröffentlichen erstellt den Remote-Branch und richtet das Tracking ein."
: "This branch exists only locally. Publish creates the remote branch and configures tracking.")
: "Push";
function runHistoryAction(action: () => void) {
historyOpen = false;
@@ -103,10 +111,13 @@
<button
class="repo-action sync-primary"
class:publish-local={localOnly}
onclick={onPush}
disabled={!hasRepository || isBusy}
title="Push"
aria-label={ahead > 0
title={pushTitle}
aria-label={localOnly
? pushTitle
: ahead > 0
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
: "Push"}
>
@@ -115,8 +126,12 @@
{:else}
<Upload size={15} aria-hidden="true" />
{/if}
<span class="repo-action-label">Push</span>
{#if ahead > 0}<span class="repo-action-count ahead">{ahead}</span>{/if}
<span class="repo-action-label">{pushLabel}</span>
{#if localOnly}
<span class="repo-action-local-marker"><CloudOff size={9} aria-hidden="true" />{isGerman ? "NUR LOKAL" : "LOCAL"}</span>
{:else if ahead > 0}
<span class="repo-action-count ahead">{ahead}</span>
{/if}
</button>
<div class="repo-history-wrap">
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
+1 -1
View File
@@ -85,7 +85,7 @@
const key = CRED_KEYS[target];
const trimmed = value.trim();
if (trimmed) {
await credSave(key, "api-key", trimmed, null);
await credSave(key, "api-key", trimmed);
} else {
await credDelete(key);
}
+2 -21
View File
@@ -17,7 +17,7 @@
action: "push" | "pull" | "fetch" | "clone";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
onSubmit: (username: string, password: string, save: boolean) => void;
onCancel: () => void;
}
@@ -36,7 +36,6 @@
let password = $state("");
let showPassword = $state(false);
let saveSession = $state(true);
let expiresAt = $state("");
let canSubmit = $derived(
!isBusy &&
@@ -62,12 +61,7 @@
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
onSubmit(
mode === "token" ? "oauth2" : username,
password,
saveSession,
saveSession && expiresAt ? expiresAt : null,
);
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
}
</script>
@@ -191,19 +185,6 @@
</div>
{/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">
<label class="cred-save">
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
+78
View File
@@ -1361,6 +1361,45 @@
label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [
{
id: "changelog-2026-8-3",
title: "Version 2026.8.3",
summary: "Dieses Release verbindet Gitty enger mit deinen Entwicklungswerkzeugen und macht Branches, Historie und Vergleiche deutlich leistungsfähiger.",
steps: [
"Externe Tools: Editor, Diff-Tool, Merge-Tool, Terminal und Dateimanager lassen sich in den neu gestalteten Einstellungen erkennen, auswählen und individuell konfigurieren.",
"VS Code, JetBrains-IDEs, Beyond Compare und weitere unterstützte Programme werden in einem eigenen Fenster geöffnet; dokumentierte Ergebnis-Codes werden beim Schließen korrekt behandelt.",
"Git Notes: Commits erhalten lokale Notizen, ohne ihre Historie umzuschreiben. Notizen lassen sich bearbeiten, löschen sowie gezielt vom Remote abrufen oder dorthin übertragen.",
"Vollständiger Branch-Vergleich: Lokale Branches, Remote-Branches und Commits können direkt ausgewählt und dateiweise im Side-by-Side-Diff verglichen werden.",
"Remote-Branches lassen sich im Kontextmenü sicher umbenennen. Gitty schützt dabei vorhandene Ziel-Branches und zwischenzeitlich geänderte Remote-Stände.",
"Der überarbeitete Commit-Graph zeigt Branches kompakter, reduziert überladene Commit-Zeilen und blendet zusätzliche Flag-Details beim Darüberfahren ein.",
"Noch nicht veröffentlichte Branches sind als „Nur lokal“ erkennbar in der Werkzeugleiste, Repository-Übersicht, Statuszeile und direkt an der Branch-Flag. Die erste Push-Aktion heißt passend „Veröffentlichen“.",
"Branch-Sichtbarkeit, feinere Graph-Verbindungen und ein kleineres Mindestmaß des Verlaufsbereichs verbessern die Übersicht bei großen Repositories.",
"Die neue Befehlspalette öffnet Aktionen, Dateien und Commits schneller; asynchrone Git-Befehle halten Gitty auch bei langsameren Operationen reaktionsfähig.",
],
note: "Der Branch-Vergleich zeigt die vollständig festgeschriebenen Zustände der beiden Branch-Spitzen. Nicht commitete Änderungen im Arbeitsverzeichnis sind nicht enthalten.",
},
{
id: "changelog-2026-8-2",
title: "Version 2026.8.2",
summary: "Dieses Release stabilisiert die Darstellung komplexer Verläufe und verbessert die Veröffentlichung neuer Gitty-Versionen.",
steps: [
"Branch-Farben bleiben über Eltern-Lanes hinweg stabil, sodass sich Linien in längeren und verzweigten Historien leichter verfolgen lassen.",
"Release-Artefakte werden automatisch und ohne doppelte Dateien an das passende Gitea-Release angehängt.",
"Beim Beenden der Anwendung wird die Telemetrie zuverlässiger abgeschlossen.",
],
},
{
id: "changelog-2026-8-1",
title: "Version 2026.8.1",
summary: "Dieses Release macht große Commit-Verläufe und die Historie einzelner Dateien leichter zugänglich und verbessert die Paketverteilung.",
steps: [
"Die Commit-Historie lädt ältere Einträge seitenweise nach und ist nicht mehr auf die erste Ergebnismenge begrenzt.",
"Die Dateihistorie öffnet sich aus dem Explorer-Kontextmenü in einem eigenen, größeren Dialog statt in einem dauerhaft belegten Seitenbereich.",
"Dialoge reagieren konsistenter auf die Escape-Taste.",
"Windows- und Ubuntu-Releases sowie der AUR-Paketablauf wurden erweitert und robuster gemacht.",
"Die Arch-Linux-Anleitung verwendet jetzt das AUR-Paket gitty-desktop; SSH-Einrichtung, Zeitlimits und Wiederholungsversuche wurden verbessert.",
],
},
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
@@ -1411,6 +1450,45 @@
label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [
{
id: "changelog-2026-8-3",
title: "Version 2026.8.3",
summary: "This release connects Gitty more closely with your development tools and makes branches, history, and comparisons substantially more capable.",
steps: [
"External tools: editors, diff tools, merge tools, terminals, and file managers can be detected, selected, and customized in the redesigned settings.",
"VS Code, JetBrains IDEs, Beyond Compare, and other supported applications open in a separate window; documented result codes are handled correctly when they close.",
"Git Notes: attach local notes to commits without rewriting history. Notes can be edited, deleted, fetched from a remote, or pushed explicitly.",
"Complete branch comparison: choose local branches, remote branches, or commits and inspect every changed file in a side-by-side diff.",
"Remote branches can be renamed safely from the context menu. Gitty protects existing destination branches and remote branches that changed after the last fetch.",
"The redesigned commit graph presents branches more compactly, reduces crowded commit rows, and reveals additional flag details on hover.",
"Unpublished branches are clearly marked as Local only in the toolbar, repository summary, status bar, and on the graph flag. Their first push is labeled Publish.",
"Branch visibility controls, refined graph connectors, and a smaller minimum history width improve navigation in large repositories.",
"The new command palette opens actions, files, and commits faster; asynchronous Git commands keep Gitty responsive during slower operations.",
],
note: "Branch comparison uses the fully committed state at each branch tip. Uncommitted working-tree changes are not included.",
},
{
id: "changelog-2026-8-2",
title: "Version 2026.8.2",
summary: "This release stabilizes complex history rendering and improves publication of new Gitty versions.",
steps: [
"Branch colors remain stable across parent lanes, making longer and branching histories easier to follow.",
"Release artifacts are attached to the matching Gitea release automatically without uploading duplicates.",
"Telemetry cleanup completes more reliably while the application is shutting down.",
],
},
{
id: "changelog-2026-8-1",
title: "Version 2026.8.1",
summary: "This release makes large commit histories and individual file histories easier to access and improves package distribution.",
steps: [
"Commit history loads older entries page by page instead of stopping after the initial result set.",
"File history opens from the explorer context menu in a dedicated larger dialog instead of occupying a permanent workspace panel.",
"Dialogs respond more consistently to the Escape key.",
"Windows and Ubuntu publishing plus the AUR package workflow were expanded and made more robust.",
"The Arch Linux guide now uses the gitty-desktop AUR package; SSH setup, timeouts, and retry handling were improved.",
],
},
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
+37 -27
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, Cherry, ChevronDown, ChevronRight, 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";
interface GraphSegment {
@@ -30,6 +30,7 @@
kind: CommitRefKind;
current: boolean;
trackedRemote: string;
localOnly: boolean;
representedBranches: string[];
}
@@ -57,6 +58,7 @@
interface Props {
commits: GitCommit[];
localBranchNames: string[];
localBranchUpstreams: Record<string, string>;
remoteBranchNames: string[];
activeBranch: string;
activeUpstream: string;
@@ -84,6 +86,7 @@
let {
commits = [],
localBranchNames = [],
localBranchUpstreams = {},
remoteBranchNames = [],
activeBranch = "",
activeUpstream = "",
@@ -541,20 +544,13 @@
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
}
function branchNameWithoutRemote(branch: string): string {
const slash = branch.indexOf("/");
return slash === -1 ? branch : branch.slice(slash + 1);
}
function remoteName(branch: string): string {
return branch.split("/", 1)[0] ?? branch;
function configuredUpstreamForBranch(local: string): string {
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
}
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
if (local === activeBranch && activeUpstream && remoteBranches.includes(activeUpstream)) {
return activeUpstream;
}
return remoteBranches.find((remote) => branchNameWithoutRemote(remote) === local) ?? "";
const configuredUpstream = configuredUpstreamForBranch(local);
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
}
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
@@ -594,6 +590,7 @@
const remainingRemote = [...remote];
const branches: CommitBranchDecoration[] = [...local].map((label) => {
const configuredUpstream = configuredUpstreamForBranch(label);
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
return {
@@ -601,6 +598,7 @@
kind: "local",
current: label === activeBranch,
trackedRemote,
localOnly: !configuredUpstream,
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
};
});
@@ -611,6 +609,7 @@
kind: "head",
current: true,
trackedRemote: "",
localOnly: false,
representedBranches: [],
});
}
@@ -620,6 +619,7 @@
kind: "remote" as const,
current: false,
trackedRemote: "",
localOnly: false,
representedBranches: [label],
})));
@@ -645,7 +645,6 @@
}
function branchStatusLabel(branch: CommitBranchDecoration): string {
if (branch.trackedRemote) return `✓ ${remoteName(branch.trackedRemote)}`;
if (branch.label === activeBranch) {
const parts = [];
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
@@ -657,8 +656,14 @@
}
function branchDecorationTitle(branch: CommitBranchDecoration): string {
if (branch.trackedRemote) return `${branch.label} · up to date with ${branch.trackedRemote}`;
if (branch.localOnly) {
const status = branchStatusLabel(branch);
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
}
const status = branchStatusLabel(branch);
if (branch.trackedRemote) {
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
}
if (status) return `${branch.label} · ${status}`;
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
}
@@ -851,19 +856,23 @@
<div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references">
{#if refSummary.primaryBranch}
<span
class="compact-ref-chip branch"
class:current={refSummary.primaryBranch.current}
class:remote={refSummary.primaryBranch.kind === "remote"}
title={branchDecorationTitle(refSummary.primaryBranch)}
>
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
<span>{refSummary.primaryBranch.label}</span>
{#if branchStatusLabel(refSummary.primaryBranch)}
<small class:up-to-date={Boolean(refSummary.primaryBranch.trackedRemote)}>
{#if refSummary.primaryBranch.trackedRemote}<Check size={9} aria-hidden="true" />{/if}
{branchStatusLabel(refSummary.primaryBranch).replace(/^✓\s*/, "")}
</small>
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
<span
class="compact-ref-chip branch"
class:current={refSummary.primaryBranch.current}
class:remote={refSummary.primaryBranch.kind === "remote"}
title={branchDecorationTitle(refSummary.primaryBranch)}
>
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
<span>{refSummary.primaryBranch.label}</span>
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
{/if}
</span>
{#if refSummary.primaryBranch.localOnly}
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
LOCAL
</span>
{/if}
</span>
{/if}
@@ -899,6 +908,7 @@
<i aria-hidden="true"></i>{branch.label}
{#if branch.current}<small>Current</small>{/if}
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
</span>
{/each}
</div>
-9
View File
@@ -1,5 +1,3 @@
import type { StoredCredential } from "./types";
/**
* Derives a credential key from a remote URL, scoped to host + organisation
* the same granularity Azure DevOps / GitHub use. Examples:
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
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:";
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 });
}
export function credSave(
key: string,
username: string,
password: string,
expiresAt: string | null,
): Promise<void> {
return invoke<void>("cred_save", { key, username, password, expiresAt });
export function credSave(key: string, username: string, password: string): Promise<void> {
return invoke<void>("cred_save", { key, username, password });
}
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> {
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);
const startedAtMs = Date.now();
+1 -1
View File
@@ -129,6 +129,7 @@ export interface GitBranch {
name: string;
current: boolean;
remote: boolean;
upstream: string | null;
}
export interface GitWorktree {
@@ -309,5 +310,4 @@ export interface ReflogEntry {
export interface StoredCredential {
username: string;
password: string;
expiresAt?: string | null;
}