feat(external-tools): add cross-platform external tool discovery

Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
This commit is contained in:
Christoph Brandau
2026-08-13 14:08:02 +02:00
parent 3eb554fee7
commit 15d1f2bfd6
15 changed files with 3721 additions and 125 deletions
+352 -6
View File
@@ -18,6 +18,7 @@
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommandPalette from "./lib/components/CommandPalette.svelte";
import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
@@ -56,12 +57,15 @@
createBranch,
createTag,
deleteBranch,
deleteCommitNote,
deleteTag,
deleteRemoteBranch,
initRepository,
diffFileAgainstWorkingTree,
compareFileToParent,
fetchCommitNotes,
fetchRemote,
getCommitNote,
getFileBlame,
getStatus,
lastCommitMessage,
@@ -86,18 +90,24 @@
pruneWorktrees,
pull,
push,
pushCommitNotes,
pushTag,
removeRemote,
removeWorktree,
repairWorktree,
revertCommit,
setBranchUpstream,
setCommitNote,
updateRemote,
renameBranch,
rebaseAbort,
rebaseBranch,
rebaseContinue,
getRemoteUrl,
detectExternalTools,
launchExternalDiff,
launchExternalMerge,
launchExternalTool,
credLoad,
credSave,
credDelete,
@@ -131,8 +141,11 @@
AnalyticsSettings,
CommitAiPhase,
ConflictFile,
DetectedExternalTool,
ExplorerNode,
ExplorerNodeKind,
ExternalDiffScope,
ExternalToolsSettings,
GitBlameLine,
GitBranch as GitBranchInfo,
GitCommit,
@@ -157,6 +170,12 @@
RepositoryBundle,
StoredCredential,
} from "./lib/types";
import {
defaultExternalToolsSettings,
externalToolDisplayName,
normaliseExternalToolsSettings,
resolveDetectedExternalToolPrograms,
} from "./lib/externalTools";
import {
orgKeyFromUrl,
@@ -223,6 +242,7 @@
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
const APP_THEME_KEY = "gitlite.theme.v1";
const APP_LANGUAGE_KEY = "gitlite.language.v1";
const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.v1";
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
@@ -289,6 +309,15 @@
let expandedExplorerPaths = new Set<string>();
let expandedCommitHashes = new Set<string>();
let selectedCommitHash = "";
let commitNoteTarget: GitCommit | null = null;
let commitNoteRepoPath = "";
let commitNoteText = "";
let commitNoteRemotes: GitRemote[] = [];
let commitNotePreferredRemote = "";
let commitNoteLoading = false;
let commitNoteBusy = false;
let commitNoteError = "";
let commitNoteStatus = "";
let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false;
let fileHistoryError = "";
@@ -319,6 +348,11 @@
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let appTheme: AppTheme = loadThemePreference();
let appLanguage: AppLanguage = loadLanguagePreference();
let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
let externalToolsConfigured = hasStoredExternalToolsSettings();
let detectedExternalTools: DetectedExternalTool[] = [];
let externalToolsDetectionPending = true;
let externalToolsDetectionUnavailable = false;
let localModelOptions: LocalModelOption[] = [];
let errorMessage = "";
let operation = "";
@@ -481,6 +515,11 @@
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
$: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools);
$: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools);
$: terminalToolName = externalToolDisplayName("terminal", externalToolsSettings.terminal, detectedExternalTools);
$: fileManagerToolName = externalToolDisplayName("fileManager", externalToolsSettings.fileManager, detectedExternalTools);
$: applyThemePreference(appTheme);
$: applyLanguagePreference(appLanguage);
@@ -491,6 +530,7 @@
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
void runStartupSequence();
void refreshDetectedExternalTools();
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
window.addEventListener("beforeunload", handleAppShutdown);
window.addEventListener("pagehide", handleAppShutdown);
@@ -769,7 +809,7 @@
}
async function autoRefreshTick() {
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
const path = activeRepoPath;
autoRefreshInFlight = true;
try {
@@ -941,16 +981,19 @@
if (appTheme === "system") applyThemePreference(appTheme);
}
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean) {
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
const autoRefreshWasEnabled = autoRefreshEnabled;
analyticsSettings = next;
appTheme = nextTheme;
appLanguage = nextLanguage;
autoRefreshEnabled = nextAutoRefresh;
externalToolsSettings = nextExternalTools;
persistAnalyticsSettings(next);
persistThemePreference(nextTheme);
persistLanguagePreference(nextLanguage);
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
persistExternalToolsSettings(nextExternalTools);
externalToolsConfigured = true;
setTelemetryEnabled(next.enabled);
appSettingsOpen = false;
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
@@ -1794,6 +1837,14 @@
selectedExplorerKind = "file";
expandedExplorerPaths = new Set();
expandedCommitHashes = new Set();
commitNoteTarget = null;
commitNoteRepoPath = "";
commitNoteText = "";
commitNoteRemotes = [];
commitNoteLoading = false;
commitNoteBusy = false;
commitNoteError = "";
commitNoteStatus = "";
fileHistory = [];
fileHistoryLoading = false;
fileHistoryError = "";
@@ -2854,6 +2905,143 @@
});
}
function closeCommitNoteDialog() {
if (commitNoteBusy) return;
commitNoteTarget = null;
commitNoteRepoPath = "";
commitNoteText = "";
commitNoteRemotes = [];
commitNotePreferredRemote = "";
commitNoteLoading = false;
commitNoteError = "";
commitNoteStatus = "";
}
async function openCommitNoteDialog(commit: GitCommit) {
if (!activeRepoPath || commitNoteBusy) return;
const repo = activeRepoPath;
selectedCommitHash = commit.hash;
commitNoteTarget = commit;
commitNoteRepoPath = repo;
commitNoteText = "";
commitNoteRemotes = [];
commitNotePreferredRemote = selectedRemote;
commitNoteLoading = true;
commitNoteError = "";
commitNoteStatus = "";
const [noteResult, remotesResult] = await Promise.allSettled([
getCommitNote(repo, commit.hash),
listRemotes(repo),
]);
if (commitNoteRepoPath !== repo || commitNoteTarget?.hash !== commit.hash) return;
if (noteResult.status === "fulfilled") {
commitNoteText = noteResult.value ?? "";
} else {
commitNoteError = errorToMessage(noteResult.reason);
}
if (remotesResult.status === "fulfilled") {
commitNoteRemotes = remotesResult.value;
commitNotePreferredRemote = remotesResult.value.some((remote) => remote.name === selectedRemote)
? selectedRemote
: (remotesResult.value[0]?.name ?? "");
} else if (!commitNoteError) {
commitNoteError = errorToMessage(remotesResult.reason);
}
commitNoteLoading = false;
}
async function saveActiveCommitNote(note: string) {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
if (!commit || !repo || commitNoteBusy || !note.trim()) return;
commitNoteBusy = true;
commitNoteError = "";
commitNoteStatus = "";
try {
await setCommitNote(repo, commit.hash, note);
commitNoteText = note;
commitNoteStatus = appLanguage === "de"
? "Notiz gespeichert. Der Commit-Hash ist unverändert."
: "Note saved. The commit hash is unchanged.";
trackEvent("commit_note_saved");
} catch (error) {
commitNoteError = errorToMessage(error);
} finally {
commitNoteBusy = false;
}
}
async function deleteActiveCommitNote() {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
if (!commit || !repo || commitNoteBusy) return;
commitNoteBusy = true;
commitNoteError = "";
commitNoteStatus = "";
try {
await deleteCommitNote(repo, commit.hash);
commitNoteText = "";
commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted.";
trackEvent("commit_note_deleted");
} catch (error) {
commitNoteError = errorToMessage(error);
} finally {
commitNoteBusy = false;
}
}
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;
}
function commitNoteRemoteError(error: unknown): string {
const raw = errorToMessage(error);
if (!isAuthError(raw)) return stripAuthPrefix(raw);
const detail = summarizeGitError(stripAuthPrefix(raw));
return appLanguage === "de"
? `${detail || "Anmeldung fehlgeschlagen."} Bitte zuerst über Pull oder Push bei diesem Remote anmelden.`
: `${detail || "Sign-in failed."} Sign in to this remote using Pull or Push first.`;
}
async function syncActiveCommitNotes(remote: string, direction: "fetch" | "push") {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
if (!commit || !repo || !remote || commitNoteBusy) return;
commitNoteBusy = true;
commitNoteError = "";
commitNoteStatus = "";
try {
const credential = await storedCredentialForNoteRemote(remote, direction);
if (direction === "fetch") {
await fetchCommitNotes(repo, remote, credential?.username, credential?.password);
commitNoteText = (await getCommitNote(repo, commit.hash)) ?? "";
commitNoteStatus = appLanguage === "de"
? `Notizen von ${remote} geladen und zusammengeführt.`
: `Notes fetched from ${remote} and merged.`;
trackEvent("commit_notes_fetched");
} else {
await pushCommitNotes(repo, remote, credential?.username, credential?.password);
commitNoteStatus = appLanguage === "de"
? `Notizen zu ${remote} gesendet.`
: `Notes pushed to ${remote}.`;
trackEvent("commit_notes_pushed");
}
} catch (error) {
commitNoteError = commitNoteRemoteError(error);
} finally {
commitNoteBusy = false;
}
}
async function cherryPickFromCommit(commit: GitCommit) {
if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
await runOperation(`Cherry-picking ${commit.short_hash}`, async () => {
@@ -3752,6 +3940,104 @@
}
}
async function openExternalFileDiff(filePath: string, scope: ExternalDiffScope, source: "status" | "explorer" | "diff-dialog") {
if (!activeRepoPath || !filePath || isBusy) return;
await runOperation(`Opening ${filePath} in ${diffToolName}`, async () => {
await launchExternalDiff(activeRepoPath, filePath, externalToolsSettings.diff, scope);
trackEvent("external_tool_opened", { kind: "diff", scope: source });
});
}
async function openPreferredFileDiff(file: GitFileStatus, staged: boolean) {
if (externalToolsSettings.diffOpenMode === "external") {
await openExternalFileDiff(file.path, staged ? "staged" : "unstaged", "status");
return;
}
await openLinePatch(file, staged);
}
async function openCurrentLinePatchExternally() {
if (!linePatchFile) return;
await openExternalFileDiff(linePatchFile.path, linePatchStaged ? "staged" : "unstaged", "diff-dialog");
}
function loadExternalToolsSettings(): ExternalToolsSettings {
try {
return normaliseExternalToolsSettings(JSON.parse(localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) ?? "null"));
} catch {
return defaultExternalToolsSettings();
}
}
function hasStoredExternalToolsSettings(): boolean {
try {
return localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) != null;
} catch {
return false;
}
}
function persistExternalToolsSettings(next: ExternalToolsSettings) {
try {
localStorage.setItem(EXTERNAL_TOOLS_SETTINGS_KEY, JSON.stringify(next));
} catch {
// Local storage is best-effort only; built-in defaults remain usable.
}
}
async function refreshDetectedExternalTools(applyDetectedDefaults = true) {
externalToolsDetectionPending = true;
externalToolsDetectionUnavailable = false;
try {
detectedExternalTools = await detectExternalTools();
if (applyDetectedDefaults) {
externalToolsSettings = externalToolsConfigured
? resolveDetectedExternalToolPrograms(externalToolsSettings, detectedExternalTools)
: defaultExternalToolsSettings(detectedExternalTools);
}
} catch {
detectedExternalTools = [];
externalToolsDetectionUnavailable = true;
} finally {
externalToolsDetectionPending = false;
}
}
async function openActiveRepoInEditor() {
if (!activeRepoPath || isBusy) return;
try {
await launchExternalTool(activeRepoPath, externalToolsSettings.editor);
trackEvent("external_tool_opened", { kind: "editor", scope: "repository" });
} catch (error) { errorMessage = errorToMessage(error); }
}
async function openActiveRepoTerminal() {
if (!activeRepoPath || isBusy) return;
try { await launchExternalTool(activeRepoPath, externalToolsSettings.terminal); trackEvent("external_tool_opened", { kind: "terminal" }); }
catch (error) { errorMessage = errorToMessage(error); }
}
async function openActiveRepoFileManager() {
if (!activeRepoPath || isBusy) return;
try { await launchExternalTool(activeRepoPath, externalToolsSettings.fileManager); trackEvent("external_tool_opened", { kind: "file_manager" }); }
catch { await openActiveRepoInExplorer(); }
}
async function openExplorerFileInEditor(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file" || isBusy) return;
try {
await launchExternalTool(activeRepoPath, externalToolsSettings.editor, node.path);
trackEvent("external_tool_opened", { kind: "editor", scope: "file" });
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function compareExplorerFileExternally(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file" || !node.tracked || isBusy) return;
await openExternalFileDiff(node.path, "head", "explorer");
}
async function openFileFromCommandPalette(file: GitRepositoryFile) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
@@ -3932,21 +4218,40 @@
async function openResolveDialog() {
if (!hasConflicts || isBusy) return;
const first = conflictedFiles[0].path;
await runOperation("Loading conflicts", async () => {
const openExternally = externalToolsSettings.mergeOpenMode === "external";
await runOperation(openExternally ? `Opening ${first} in ${mergeToolName}` : "Loading conflicts", async () => {
preparedResolutions = {};
resolveDialogOpen = true;
await loadConflict(first);
trackEvent("resolve_dialog_opened", {
conflicts: conflictedFiles.length,
});
if (openExternally) await launchMergeToolForConflict(first);
});
}
async function selectConflictFile(path: string) {
if (path === conflictTarget || isBusy) return;
await runOperation(`Loading ${path}`, async () => {
const openExternally = externalToolsSettings.mergeOpenMode === "external";
await runOperation(openExternally ? `Opening ${path} in ${mergeToolName}` : `Loading ${path}`, async () => {
await loadConflict(path);
trackEvent("conflict_file_selected");
if (openExternally) await launchMergeToolForConflict(path);
});
}
async function launchMergeToolForConflict(path: string) {
if (!activeRepoPath || !path) return;
await launchExternalMerge(activeRepoPath, path, externalToolsSettings.merge);
await loadConflict(path);
await refreshExplorerFiles(activeRepoPath);
trackEvent("external_tool_opened", { kind: "merge", scope: "conflict" });
}
async function openConflictInExternalMerge(path: string) {
if (!activeRepoPath || !path || isBusy) return;
await runOperation(`Opening ${path} in ${mergeToolName}`, async () => {
await launchMergeToolForConflict(path);
});
}
@@ -4080,6 +4385,9 @@
ahead={status?.ahead ?? 0}
behind={status?.behind ?? 0}
language={appLanguage}
editorName={editorToolName}
terminalName={terminalToolName}
fileManagerName={fileManagerToolName}
onFetch={fetchRepo}
onPull={pullRepo}
onPush={pushRepo}
@@ -4088,7 +4396,9 @@
onCompare={openCompareSelect}
onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer}
onOpenInEditor={openActiveRepoInEditor}
onOpenTerminal={openActiveRepoTerminal}
onOpenInExplorer={openActiveRepoFileManager}
onFetchPrune={fetchPruneRepo}
onForcePush={forcePushRepo}
onSyncOptions={openSyncOptions}
@@ -4478,11 +4788,16 @@
{selectedExplorerKind}
{hasRepository}
{isBusy}
language={appLanguage}
editorName={editorToolName}
diffName={diffToolName}
onToggleFolder={toggleExplorerFolder}
onExpandAllFolders={expandAllExplorerFolders}
onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer}
onOpenInEditor={openExplorerFileInEditor}
onExternalDiff={compareExplorerFileExternally}
onFileHistory={openFileHistoryDialog}
onBlame={openBlame}
collapsed={explorerPanelCollapsed}
@@ -4540,7 +4855,7 @@
onUnstage={unstageFile}
onDiscard={discardFiles}
onDiscardMany={discardChanges}
onPatch={openLinePatch}
onPatch={openPreferredFileDiff}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
/>
@@ -4629,6 +4944,7 @@
onCreateBranchFromCommit={openNewBranchDialog}
onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit}
onOpenCommitNote={openCommitNoteDialog}
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes);
@@ -4709,6 +5025,11 @@
theme={appTheme}
language={appLanguage}
autoRefresh={autoRefreshEnabled}
externalTools={externalToolsSettings}
detectedTools={detectedExternalTools}
detectionPending={externalToolsDetectionPending}
detectionUnavailable={externalToolsDetectionUnavailable}
onRefreshDetectedTools={() => refreshDetectedExternalTools(false)}
onSave={saveAppSettings}
onClose={() => { appSettingsOpen = false; }}
/>
@@ -4751,6 +5072,9 @@
onClose={closeLinePatch}
onRefresh={refreshLinePatch}
onApply={applyLinePatch}
language={appLanguage}
diffName={diffToolName}
onExternalDiff={openCurrentLinePatchExternally}
/>
{/await}
{/if}
@@ -4849,6 +5173,25 @@
/>
{/if}
{#if commitNoteTarget}
<CommitNoteDialog
commit={commitNoteTarget}
note={commitNoteText}
remotes={commitNoteRemotes}
preferredRemote={commitNotePreferredRemote}
language={appLanguage}
isLoading={commitNoteLoading}
isBusy={commitNoteBusy}
error={commitNoteError}
status={commitNoteStatus}
onSave={saveActiveCommitNote}
onDelete={deleteActiveCommitNote}
onFetch={(remote) => syncActiveCommitNotes(remote, "fetch")}
onPush={(remote) => syncActiveCommitNotes(remote, "push")}
onClose={closeCommitNoteDialog}
/>
{/if}
<!-- Rename a local branch from the branch context menu -->
{#if renameBranchTarget}
<RenameBranchDialog
@@ -5007,10 +5350,13 @@
{preparedResolutions}
{isBusy}
{operation}
language={appLanguage}
mergeName={mergeToolName}
onClose={() => { resolveDialogOpen = false; }}
onSelectFile={selectConflictFile}
onMarkResolved={handleMarkResolved}
onApply={applyPreparedResolutions}
onExternalMerge={openConflictInExternalMerge}
/>
{/await}
{/if}