add new Context Menu for file Explorer

fix blocking UI
when select the file in Status also select in the file History
This commit is contained in:
Christoph Brandau
2026-07-02 16:24:38 +02:00
parent 794680a696
commit e3df75cc38
11 changed files with 399 additions and 75 deletions
+76 -17
View File
@@ -27,6 +27,7 @@
commit,
compareCommits,
cancelCodeSearch,
cancelFileHistory,
applyFilePatch,
createBranch,
deleteBranch,
@@ -39,6 +40,7 @@
listRepositoryFiles,
mergeBranch,
openRepoInExplorer,
openRepositoryFile,
openRepositoryBundle,
pull,
push,
@@ -119,6 +121,7 @@
let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false;
let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = "";
let commitMessage = "";
let errorMessage = "";
let operation = "";
@@ -207,6 +210,7 @@
onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
});
// ── Auto-refresh ───────────────────────────────────────────────────────────
@@ -534,14 +538,56 @@
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
selectedExplorerPath = "";
selectedExplorerKind = "file";
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
}
}
function isCancellationMessage(message: string): boolean {
return message.toLowerCase().includes("abgebrochen");
}
function cancelActiveFileHistoryLoad() {
const requestId = activeFileHistoryRequestId;
if (!requestId) return;
void cancelFileHistory(requestId).catch(() => {});
}
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
const requestId = ++fileHistoryRequestId;
const history = file ? await listFileHistory(path, file, 100) : [];
if (requestId === fileHistoryRequestId) fileHistory = history;
cancelActiveFileHistoryLoad();
if (!path || !file) {
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
return;
}
const historyRequestId = `file-history-${requestId}-${Date.now()}`;
activeFileHistoryRequestId = historyRequestId;
fileHistoryLoading = true;
fileHistory = [];
try {
const history = await listFileHistory(path, file, 100, historyRequestId);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = history;
}
} catch (error) {
const message = errorToMessage(error);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = [];
if (!isCancellationMessage(message)) errorMessage = message;
}
} finally {
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
}
}
}
// ── Repository operations ──────────────────────────────────────────────────
@@ -1120,26 +1166,14 @@
// (isBusy/runOperation would disable every button in the app while this awaits).
// A request id guards against a slower, stale request overwriting a newer selection.
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
const requestId = ++fileHistoryRequestId;
fileHistoryLoading = true;
try {
const history = await listFileHistory(repo, path, 100);
if (requestId === fileHistoryRequestId) fileHistory = history;
} catch (error) {
if (requestId === fileHistoryRequestId) {
fileHistory = [];
errorMessage = errorToMessage(error);
}
} finally {
if (requestId === fileHistoryRequestId) fileHistoryLoading = false;
}
await refreshFileHistory(repo, path);
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
await loadSelectedFileHistory(node.path);
void loadSelectedFileHistory(node.path);
}
async function selectFileFromSearch(file: GitRepositoryFile) {
@@ -1148,7 +1182,29 @@
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
await loadSelectedFileHistory(file.path);
void loadSelectedFileHistory(file.path);
}
function selectFileFromStatus(file: GitFileStatus) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
void loadSelectedFileHistory(file.path);
}
async function openFileFromExplorer(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file") return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
void loadSelectedFileHistory(node.path);
try {
await openRepositoryFile(activeRepoPath, node.path);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function restoreSelectedFileFromCommit(target: GitCommit) {
@@ -1590,6 +1646,7 @@
onExpandAllFolders={expandAllExplorerFolders}
onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer}
/>
</aside>
@@ -1618,6 +1675,8 @@
{hasRepository}
{isBusy}
{status}
selectedFilePath={selectedExplorerPath}
onSelectFile={selectFileFromStatus}
onStage={stageFile}
onUnstage={unstageFile}
onDiscard={discardFile}
+41 -7
View File
@@ -12,6 +12,7 @@
--color-surface-dim: rgba(18, 18, 30, 0.9);
--color-surface-hover: rgba(47, 48, 78, 0.76);
--color-surface-raised: rgba(28, 29, 48, 0.88);
--color-surface-solid: #1c1d30;
--color-border: rgba(100, 108, 255, 0.28);
--color-border-subtle: rgba(255, 255, 255, 0.08);
@@ -274,6 +275,15 @@
}
.titlebar-brand svg { color: #ffd343; filter: drop-shadow(0 0 10px rgba(255,211,67,0.3)); flex-shrink: 0; }
.tb-version {
margin-left: 1px;
color: rgba(255,255,255,0.32);
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono);
letter-spacing: 0.01em;
}
.titlebar-info {
display: flex;
align-items: center;
@@ -943,6 +953,22 @@
.file-row { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.file-row + .file-row { margin-top: 6px; }
.file-row.selected { border-color: rgba(90,140,248,0.36); background: rgba(90,140,248,0.1); }
.file-title-button {
justify-content: flex-start;
width: 100%;
min-height: 24px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
}
.file-title-button:hover:not(:disabled) {
background: transparent;
color: var(--color-accent);
}
.file-title strong {
display: block;
@@ -1175,8 +1201,8 @@
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
.branch-context-menu {
position: absolute;
.branch-context-menu,
.explorer-context-menu {
z-index: 120;
display: grid;
gap: 2px;
@@ -1184,11 +1210,15 @@
padding: 5px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
background: var(--color-surface-solid);
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
}
.branch-context-menu button {
.branch-context-menu { position: absolute; }
.explorer-context-menu { position: fixed; }
.branch-context-menu button,
.explorer-context-menu button {
display: flex;
align-items: center;
justify-content: flex-start;
@@ -1205,7 +1235,8 @@
text-align: left;
}
.branch-context-menu button:hover:not(:disabled) {
.branch-context-menu button:hover:not(:disabled),
.explorer-context-menu button:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.06);
color: var(--color-ink);
@@ -1221,13 +1252,16 @@
color: #ffd0d6;
}
.branch-context-menu button:disabled {
.branch-context-menu button:disabled,
.explorer-context-menu button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
/* --- Explorer --- */
.explorer-panel { position: relative; }
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.explorer-bulk-button {
width: 26px;
+6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
@@ -23,12 +24,14 @@
const win = getCurrentWindow();
let isMaximized = false;
let unlisten: (() => void) | undefined;
let appVersion = "";
onMount(async () => {
isMaximized = await win.isMaximized();
unlisten = await win.onResized(async () => {
isMaximized = await win.isMaximized();
});
appVersion = await getVersion();
});
onDestroy(() => {
@@ -58,6 +61,9 @@
<line x1="12" y1="12" x2="12" y2="15" />
</svg>
<span data-tauri-drag-region>GitLite</span>
{#if appVersion}
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
{/if}
</div>
<!-- Center: repo + branch info -->
+1 -1
View File
@@ -252,7 +252,7 @@
}
</script>
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} />
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head">
+58 -1
View File
@@ -17,6 +17,7 @@
FileVideo,
Folder,
FolderOpen,
ExternalLink,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
@@ -34,6 +35,7 @@
onExpandAllFolders: () => void;
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
}
let {
@@ -47,8 +49,13 @@
onExpandAllFolders = () => {},
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
if (!next) return current;
if (!current) return next;
@@ -154,12 +161,39 @@
return "text";
}
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
if (node.kind !== "file") return;
event.preventDefault();
event.stopPropagation();
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
}
function closeFileContextMenu() {
contextNode = null;
}
function openContextFile() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenFile(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
<div class="section-head">
<div>
<span class="eyebrow">Explorer</span>
@@ -264,6 +298,7 @@
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, node)}
disabled={isBusy}
title={`Show history for ${node.path}`}
>
@@ -279,4 +314,26 @@
{/each}
</div>
{/if}
</section>
{#if contextNode}
<div
class="explorer-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button
type="button"
role="menuitem"
onclick={openContextFile}
disabled={contextNode.status === "deleted"}
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
>
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
</div>
{/if}
+16 -2
View File
@@ -9,6 +9,8 @@
hasRepository: boolean;
isBusy: boolean;
status: GitStatus | null;
selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void;
onStage: (file: GitFileStatus) => void;
onUnstage: (file: GitFileStatus) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void;
@@ -24,6 +26,8 @@
hasRepository = false,
isBusy = false,
status = null,
selectedFilePath = "",
onSelectFile = () => {},
onStage = () => {},
onUnstage = () => {},
onDiscard = () => {},
@@ -102,9 +106,19 @@
{:else}
<div class="overflow-auto p-2">
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article class="file-row">
<article
class="file-row"
class:selected={selectedFilePath === file.path}
>
<div class="file-title">
<strong title={displayPath(file)}>{fileName(file)}</strong>
<button
class="file-title-button"
type="button"
onclick={() => onSelectFile(file)}
title={`Select ${displayPath(file)} in Explorer`}
>
<strong>{fileName(file)}</strong>
</button>
</div>
<div class="change-lanes">
+15 -2
View File
@@ -21,6 +21,10 @@ export function openRepoInExplorer(path: string): Promise<void> {
return invoke<void>("open_repo_in_explorer", { path });
}
export function openRepositoryFile(path: string, file: string): Promise<void> {
return invoke<void>("open_repository_file", { path, file });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
@@ -143,8 +147,17 @@ export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]>
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
export function listFileHistory(
path: string,
file: string,
limit = 100,
requestId?: string,
): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_file_history", { path, file, limit, requestId: requestId ?? null });
}
export function cancelFileHistory(requestId: string): Promise<void> {
return invoke<void>("cancel_file_history", { requestId });
}
export function compareCommits(