feat(repositories): add favorite repositories feature

This update introduces a favorites system for repositories, allowing users
to mark certain repositories as favorites for easier access. The UI has been
modified to include a star icon for toggling favorites, and the layout has
been adjusted to display favorite repositories separately.

- Implemented favorite repository management with local storage
- Updated UI to show favorite repositories with a star icon
- Enhanced repository listing to filter and display favorites
This commit is contained in:
Christoph Brandau
2026-07-08 14:07:50 +02:00
parent 6f1771f8b6
commit 5e46995c89
2 changed files with 75 additions and 14 deletions
+68 -13
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
@@ -153,6 +153,7 @@
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
@@ -173,7 +174,8 @@
let activeView: AppView = "management";
let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = [];
// Last-seen branch/ahead/behind/changed for repos that are known (recent/all)
let favoriteRepoPaths: string[] = [];
// Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
// but not currently open as a tab — keyed by normalized path (repoKey).
let repoStatusCache: Record<string, RepoTab> = {};
let repoSearch = "";
@@ -330,7 +332,7 @@
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
.map(repoRowFromPath)
.filter(repoMatchesSearch);
$: allRepoRows = uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
$: favoriteRepoRows = favoriteRepoPaths
.map(repoRowFromPath)
.filter(repoMatchesSearch);
@@ -434,10 +436,10 @@
// One repo per tick, round-robin: spreads the git subprocess cost over time
// instead of firing N fetches at once when many repos are open.
// Every repo we know about besides the active one: open tabs (minus the active
// tab) plus recent repos that aren't currently open — the same universe the
// Recent/All repositories lists in Repository Management draw from.
// tab) plus recent/favorite repos that aren't currently open — the same
// universe the Repository Management lists draw from.
function knownRepoPathsForBackground(): string[] {
return uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
return uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths, ...favoriteRepoPaths])
.filter((path) => !(activeView === "repository" && sameRepoPath(path, activeRepoPath)));
}
@@ -846,12 +848,16 @@
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
const favoriteValue = JSON.parse(localStorage.getItem(FAVORITE_REPOS_KEY) ?? "[]") as unknown;
const openPaths = Array.isArray(openValue)
? openValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: [];
const recentPaths = Array.isArray(recentValue)
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: [];
const favoritePaths = Array.isArray(favoriteValue)
? favoriteValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: [];
// Seed from the last-known status cache so tabs show real data immediately
// on startup, instead of blank until the background poll catches up.
@@ -862,9 +868,11 @@
: { path, name: repoNameFromPath(path), branch: null, ahead: 0, behind: 0, changed: 0, lastOpened: 0 };
});
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
favoriteRepoPaths = uniqueRepoPaths(favoritePaths);
} catch {
repoTabs = [];
recentRepoPaths = [];
favoriteRepoPaths = [];
repoStatusCache = {};
}
}
@@ -873,6 +881,7 @@
try {
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
localStorage.setItem(FAVORITE_REPOS_KEY, JSON.stringify(favoriteRepoPaths));
} catch {
// Local storage is best-effort only; the Git workflow must keep working without it.
}
@@ -1027,6 +1036,19 @@
persistRepoLists();
}
function isFavoriteRepo(path: string): boolean {
return favoriteRepoPaths.some((favoritePath) => sameRepoPath(favoritePath, path));
}
function toggleFavoriteRepo(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
favoriteRepoPaths = isFavoriteRepo(path)
? favoriteRepoPaths.filter((favoritePath) => !sameRepoPath(favoritePath, path))
: uniqueRepoPaths([path, ...favoriteRepoPaths]);
persistRepoLists();
}
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
const next: RepoTab = {
@@ -1049,7 +1071,7 @@
// Keeps last-known branch/ahead/behind/changed around under the repo's
// normalized path, independent of repoTabs — so a closed tab (or a repo
// that's only ever shown up in "recent") still displays real data instead
// of the "known repo" placeholder in the Recent/All repositories lists.
// of the "known repo" placeholder in the Recent/Favorites repositories lists.
function cacheRepoStatus(row: RepoTab) {
repoStatusCache = { ...repoStatusCache, [repoKey(row.path)]: row };
persistRepoStatusCache();
@@ -1416,6 +1438,7 @@
event?.stopPropagation();
if (isBusy) return;
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
favoriteRepoPaths = favoriteRepoPaths.filter((favoritePath) => !sameRepoPath(favoritePath, path));
persistRepoLists();
if (repoStatusCache[repoKey(path)]) {
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
@@ -2722,6 +2745,17 @@
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span>
</button>
<button
class="repo-row-icon repo-row-favorite"
class:active={isFavoriteRepo(repo.path)}
type="button"
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
disabled={isBusy}
title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
>
<Star size={14} aria-hidden="true" />
</button>
<button class="repo-row-icon" type="button" onclick={(event) => closeRepoTab(repo.path, event)} disabled={isBusy} title="Close tab" aria-label={`Close ${repo.name}`}>
<X size={14} aria-hidden="true" />
</button>
@@ -2752,6 +2786,17 @@
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span>
</button>
<button
class="repo-row-icon repo-row-favorite"
class:active={isFavoriteRepo(repo.path)}
type="button"
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
disabled={isBusy}
title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
>
<Star size={14} aria-hidden="true" />
</button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" />
</button>
@@ -2763,25 +2808,35 @@
<section class="repo-section">
<header>
<h2>All repositories</h2>
<span>{allRepoRows.length}</span>
<h2>Favorites</h2>
<span>{favoriteRepoRows.length}</span>
</header>
{#if allRepoRows.length === 0}
<div class="repo-empty">Browse for a repository to add it here.</div>
{#if favoriteRepoRows.length === 0}
<div class="repo-empty">Mark repositories with the star to keep them here.</div>
{:else}
<div class="repo-table">
{#each allRepoRows as repo (repo.path)}
{#each favoriteRepoRows as repo (repo.path)}
<div class="repo-row">
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
<span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta">
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">known repo</em>{/if}
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">favorite</em>{/if}
{#if repo.ahead > 0}<em class="ahead">{repo.ahead}</em>{/if}
{#if repo.behind > 0}<em class="behind">{repo.behind}</em>{/if}
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span>
</button>
<button
class="repo-row-icon repo-row-favorite active"
type="button"
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
disabled={isBusy}
title="Remove from favorites"
aria-label={`Remove ${repo.name} from favorites`}
>
<Star size={14} aria-hidden="true" />
</button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" />
</button>
+7 -1
View File
@@ -782,7 +782,7 @@
}
.repo-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: stretch;
min-height: 36px;
border-bottom: 1px solid rgba(255,255,255,0.035);
@@ -852,6 +852,12 @@
background: transparent;
color: var(--color-ink-faint);
}
.repo-row-favorite.active {
color: #e0c15f;
}
.repo-row-favorite.active svg {
fill: currentColor;
}
/* --- Notices --- */