feat(integrations): add review fetching and Linux keyring support
Add a cross-provider Review Center and improve credential handling. Backend integrations fetch and normalize PRs from GitHub, GitLab, Gitea, and Azure DevOps with improved timeouts and parsing. Credentials now use a global lock and support secret-tool on Linux to avoid races. - Normalize review data across providers (GitHub/GitLab/Gitea/Azure) - Serialize credential access with OnceLock and use secret-tool on Linux - Add frontend ReviewCenter component and related UI updates
This commit is contained in:
+52
-4
@@ -6,11 +6,12 @@
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
import { beginFrontendShutdown } from "./lib/telemetry";
|
||||
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
||||
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
||||
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
||||
@@ -200,6 +201,7 @@
|
||||
resolveDetectedExternalToolPrograms,
|
||||
} from "./lib/externalTools";
|
||||
import {
|
||||
configuredIntegrationSources,
|
||||
defaultGitIntegrationSettings,
|
||||
integrationCredentialKey,
|
||||
normaliseGitIntegrationSettings,
|
||||
@@ -215,7 +217,7 @@
|
||||
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
|
||||
|
||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||
type AppView = "management" | "repository";
|
||||
type AppView = "management" | "review-center" | "repository";
|
||||
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
||||
type CredentialMode = "credentials" | "token";
|
||||
type PendingDiscard =
|
||||
@@ -468,6 +470,7 @@
|
||||
let credDialogUsername = "";
|
||||
let credDialogMode: CredentialMode = "credentials";
|
||||
const rejectedCredentialKeys = new Set<string>();
|
||||
const credentialCache = new Map<string, StoredCredential | null>();
|
||||
let lastStatusFingerprint = "";
|
||||
const AUTO_REFRESH_INTERVAL = 4000;
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
@@ -581,6 +584,8 @@
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
resumeFrontend();
|
||||
|
||||
onMount(() => {
|
||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
@@ -610,7 +615,6 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
handleAppShutdown();
|
||||
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
|
||||
window.removeEventListener("beforeunload", handleAppShutdown);
|
||||
window.removeEventListener("pagehide", handleAppShutdown);
|
||||
@@ -1249,12 +1253,14 @@
|
||||
const providerConfig = integrationsToSave.providers[update.provider];
|
||||
if (update.removeToken) {
|
||||
await credDelete(key);
|
||||
credentialCache.set(key, null);
|
||||
if (azureOrganization) azureOrganization.tokenStored = false;
|
||||
else providerConfig.tokenStored = false;
|
||||
} else if (update.token) {
|
||||
const fallbackUsername = update.provider === "github" ? "x-access-token" : "oauth2";
|
||||
const username = (azureOrganization?.username ?? providerConfig.username).trim() || fallbackUsername;
|
||||
await credSave(key, username, update.token, "token");
|
||||
credentialCache.set(key, { username, password: update.token, mode: "token" });
|
||||
if (azureOrganization) azureOrganization.tokenStored = true;
|
||||
else providerConfig.tokenStored = true;
|
||||
}
|
||||
@@ -2581,6 +2587,13 @@
|
||||
void backgroundRepoStatusTick(false);
|
||||
}
|
||||
|
||||
function openReviewCenter() {
|
||||
if (isBusy) return;
|
||||
closeRepoTabContextMenu();
|
||||
activeView = "review-center";
|
||||
trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length });
|
||||
}
|
||||
|
||||
async function selectRepoTab(path: string) {
|
||||
if (isBusy) return;
|
||||
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
|
||||
@@ -3653,8 +3666,34 @@
|
||||
|
||||
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
|
||||
if (!key) return null;
|
||||
if (credentialCache.has(key)) return credentialCache.get(key) ?? null;
|
||||
const repoHost = key.startsWith("integration:") ? "" : key.split("/", 1)[0].toLowerCase();
|
||||
const integrationAliases = repoHost
|
||||
? configuredIntegrationSources(gitIntegrationSettings)
|
||||
.filter((source) => {
|
||||
try { return new URL(source.baseUrl).host.toLowerCase() === repoHost; }
|
||||
catch { return false; }
|
||||
})
|
||||
.map((source) => integrationCredentialKey(source.provider, source.accountId))
|
||||
: [];
|
||||
const candidates = [...new Set([...integrationAliases, key])];
|
||||
try {
|
||||
return await credLoad(key);
|
||||
for (const candidate of candidates) {
|
||||
const cached = credentialCache.get(candidate);
|
||||
if (cached) {
|
||||
credentialCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
if (credentialCache.has(candidate)) continue;
|
||||
const credential = await credLoad(candidate);
|
||||
credentialCache.set(candidate, credential);
|
||||
if (credential) {
|
||||
credentialCache.set(key, credential);
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
credentialCache.set(key, null);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -3931,6 +3970,7 @@
|
||||
if (save && key) {
|
||||
try {
|
||||
await credSave(key, username, password, mode);
|
||||
credentialCache.set(key, { username, password, mode });
|
||||
} catch (error) {
|
||||
credDialogError = errorToMessage(error);
|
||||
return;
|
||||
@@ -5138,6 +5178,7 @@
|
||||
{isBusy}
|
||||
language={appLanguage}
|
||||
onOpenManagement={openRepoManagement}
|
||||
onOpenReviewCenter={openReviewCenter}
|
||||
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
|
||||
onSelect={selectRepoTab}
|
||||
onClose={closeRepoTab}
|
||||
@@ -5307,6 +5348,13 @@
|
||||
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
||||
onRemoveRecent={removeRepoFromRecent}
|
||||
/>
|
||||
{:else if activeView === "review-center"}
|
||||
<ReviewCenter
|
||||
language={appLanguage}
|
||||
integrations={gitIntegrationSettings}
|
||||
loadCredential={loadStoredCredential}
|
||||
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Workspace -->
|
||||
<section
|
||||
|
||||
Reference in New Issue
Block a user