Merge pull request 'Opt/app layout' (#18) from opt/app_layout into main
Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
@@ -78,7 +78,8 @@
|
|||||||
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
|
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)"
|
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
|
||||||
|
"Bash(pkg-config --list-all)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-5
@@ -3415,16 +3415,47 @@ fn is_auth_error(details: &str) -> bool {
|
|||||||
|| d.contains("authentication required")
|
|| d.contains("authentication required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windows' CreateProcess rejects command lines longer than ~32K chars with
|
||||||
|
// "os error 206" (filename or extension too long). Staging/restoring a large
|
||||||
|
// number of files can easily exceed that, so split the paths across multiple
|
||||||
|
// invocations and concatenate their output.
|
||||||
|
const MAX_PATH_ARGS_CHARS: usize = 8_000;
|
||||||
|
|
||||||
fn run_git_with_paths(
|
fn run_git_with_paths(
|
||||||
repo: &Path,
|
repo: &Path,
|
||||||
base_args: &[&str],
|
base_args: &[&str],
|
||||||
files: &[String],
|
files: &[String],
|
||||||
) -> Result<Vec<u8>, String> {
|
) -> Result<Vec<u8>, String> {
|
||||||
let mut args = Vec::with_capacity(base_args.len() + files.len() + 1);
|
if files.is_empty() {
|
||||||
args.extend(base_args.iter().map(OsString::from));
|
let args: Vec<OsString> = base_args.iter().map(OsString::from).collect();
|
||||||
args.push(OsString::from("--"));
|
return run_git(repo, args);
|
||||||
args.extend(files.iter().map(OsString::from));
|
}
|
||||||
run_git(repo, args)
|
|
||||||
|
let mut combined = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
while start < files.len() {
|
||||||
|
let mut end = start;
|
||||||
|
let mut chunk_chars = 0usize;
|
||||||
|
while end < files.len() {
|
||||||
|
let len = files[end].len() + 1;
|
||||||
|
if end > start && chunk_chars + len > MAX_PATH_ARGS_CHARS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunk_chars += len;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
let chunk = &files[start..end];
|
||||||
|
|
||||||
|
let mut args = Vec::with_capacity(base_args.len() + chunk.len() + 1);
|
||||||
|
args.extend(base_args.iter().map(OsString::from));
|
||||||
|
args.push(OsString::from("--"));
|
||||||
|
args.extend(chunk.iter().map(OsString::from));
|
||||||
|
combined.extend(run_git(repo, args)?);
|
||||||
|
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_git_with_paths_cancellable(
|
fn run_git_with_paths_cancellable(
|
||||||
|
|||||||
+25
-2
@@ -1639,6 +1639,7 @@
|
|||||||
.branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; }
|
.branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; }
|
||||||
|
|
||||||
.branch-context-menu,
|
.branch-context-menu,
|
||||||
|
.history-context-menu,
|
||||||
.explorer-context-menu {
|
.explorer-context-menu {
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1651,10 +1652,12 @@
|
|||||||
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
|
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu { position: absolute; }
|
.branch-context-menu,
|
||||||
|
.history-context-menu { position: absolute; }
|
||||||
.explorer-context-menu { position: fixed; }
|
.explorer-context-menu { position: fixed; }
|
||||||
|
|
||||||
.branch-context-menu button,
|
.branch-context-menu button,
|
||||||
|
.history-context-menu button,
|
||||||
.explorer-context-menu button {
|
.explorer-context-menu button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1673,13 +1676,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu button:hover:not(:disabled),
|
.branch-context-menu button:hover:not(:disabled),
|
||||||
|
.history-context-menu button:hover:not(:disabled),
|
||||||
.explorer-context-menu button:hover:not(:disabled) {
|
.explorer-context-menu button:hover:not(:disabled) {
|
||||||
border-color: var(--color-border-subtle);
|
border-color: var(--color-border-subtle);
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu .menu-separator {
|
.branch-context-menu .menu-separator,
|
||||||
|
.history-context-menu .menu-separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 4px 3px;
|
margin: 4px 3px;
|
||||||
background: var(--color-border-subtle);
|
background: var(--color-border-subtle);
|
||||||
@@ -1696,6 +1701,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu button:disabled,
|
.branch-context-menu button:disabled,
|
||||||
|
.history-context-menu button:disabled,
|
||||||
.explorer-context-menu button:disabled {
|
.explorer-context-menu button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
@@ -1817,6 +1823,7 @@
|
|||||||
|
|
||||||
/* --- Commit history --- */
|
/* --- Commit history --- */
|
||||||
|
|
||||||
|
.history-panel { position: relative; }
|
||||||
.history-list { min-width: 0; padding: 6px; overflow: auto; }
|
.history-list { min-width: 0; padding: 6px; overflow: auto; }
|
||||||
|
|
||||||
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
|
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
|
||||||
@@ -2017,6 +2024,22 @@
|
|||||||
}
|
}
|
||||||
.commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
.commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
||||||
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
|
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
|
||||||
|
.commit-menu-button {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(94,110,156,0.16);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255,255,255,0.035);
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
}
|
||||||
|
.commit-menu-button:hover:not(:disabled),
|
||||||
|
.commit-menu-button[aria-expanded="true"] {
|
||||||
|
border-color: rgba(65,209,255,0.28);
|
||||||
|
background: rgba(65,209,255,0.08);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
.file-history-head { align-items: flex-start; }
|
.file-history-head { align-items: flex-start; }
|
||||||
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
|
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
|
||||||
.section-head .file-history-name {
|
.section-head .file-history-name {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -60,6 +60,10 @@
|
|||||||
let branchDialogOpen = $state(false);
|
let branchDialogOpen = $state(false);
|
||||||
let userAdjustedBranchFilter = $state(false);
|
let userAdjustedBranchFilter = $state(false);
|
||||||
let lastDefaultFilterKey = $state("");
|
let lastDefaultFilterKey = $state("");
|
||||||
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
|
let contextCommit = $state<GitCommit | null>(null);
|
||||||
|
let contextMenuX = $state(0);
|
||||||
|
let contextMenuY = $state(0);
|
||||||
|
|
||||||
function laneColor(col: number): string {
|
function laneColor(col: number): string {
|
||||||
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
||||||
@@ -203,6 +207,24 @@
|
|||||||
return labels.filter(branchIsVisible);
|
return labels.filter(branchIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
||||||
|
const directBranches = localBranchRefs(commit);
|
||||||
|
if (directBranches.length > 0) return directBranches;
|
||||||
|
|
||||||
|
const containingBranches = row?.branchLabels ?? [];
|
||||||
|
if (containingBranches.length <= 3) return containingBranches;
|
||||||
|
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||||
|
const directBranches = localBranchRefs(commit);
|
||||||
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||||
|
|
||||||
|
const containingBranches = row?.branchLabels ?? [];
|
||||||
|
if (containingBranches.length === 0) return commit.short_hash;
|
||||||
|
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function segmentIsVisible(segment: GraphSegment): boolean {
|
function segmentIsVisible(segment: GraphSegment): boolean {
|
||||||
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
||||||
}
|
}
|
||||||
@@ -316,6 +338,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isBusy) return;
|
||||||
|
|
||||||
|
if (contextCommit?.hash === commit.hash) {
|
||||||
|
closeCommitContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelRect = panelElement?.getBoundingClientRect();
|
||||||
|
const buttonRect = event.currentTarget instanceof HTMLElement
|
||||||
|
? event.currentTarget.getBoundingClientRect()
|
||||||
|
: null;
|
||||||
|
const rawX = panelRect && buttonRect ? buttonRect.right - panelRect.left - 184 : event.offsetX;
|
||||||
|
const rawY = panelRect && buttonRect ? buttonRect.bottom - panelRect.top + 4 : event.offsetY;
|
||||||
|
const maxX = Math.max(8, (panelRect?.width ?? window.innerWidth) - 192);
|
||||||
|
const maxY = Math.max(8, (panelRect?.height ?? window.innerHeight) - 96);
|
||||||
|
|
||||||
|
contextCommit = commit;
|
||||||
|
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||||
|
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCommitContextMenu() {
|
||||||
|
contextCommit = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBranchFromContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onCreateBranchFromCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onRestoreCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
handleBranchDialogKeydown(event);
|
||||||
|
}
|
||||||
|
|
||||||
function localBranchRefs(commit: GitCommit): string[] {
|
function localBranchRefs(commit: GitCommit): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
@@ -393,9 +463,9 @@
|
|||||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleBranchDialogKeydown} />
|
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">History</span>
|
<span class="eyebrow">History</span>
|
||||||
@@ -431,9 +501,14 @@
|
|||||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||||
{@const item = entry.commit}
|
{@const item = entry.commit}
|
||||||
{@const row = graphRows[rowIndex]}
|
{@const row = graphRows[rowIndex]}
|
||||||
{@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
|
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
||||||
{@const otherRefs = visibleRefs(item)}
|
{@const otherRefs = visibleRefs(item)}
|
||||||
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0}>
|
<article
|
||||||
|
class="commit-row graph-row"
|
||||||
|
class:merge-row={item.parents.length > 1}
|
||||||
|
class:root-row={item.parents.length === 0}
|
||||||
|
class:tip-row={item.refs.length > 0}
|
||||||
|
>
|
||||||
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
||||||
{#if row}
|
{#if row}
|
||||||
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
||||||
@@ -461,7 +536,7 @@
|
|||||||
class:merge={item.parents.length > 1}
|
class:merge={item.parents.length > 1}
|
||||||
class:tip={item.refs.length > 0}
|
class:tip={item.refs.length > 0}
|
||||||
class:hidden-branch={!rowGraphIsVisible(row)}
|
class:hidden-branch={!rowGraphIsVisible(row)}
|
||||||
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
|
title={commitHoverTitle(item, row)}
|
||||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||||
></span>
|
></span>
|
||||||
{#if hoverBranchRefs.length > 0}
|
{#if hoverBranchRefs.length > 0}
|
||||||
@@ -545,13 +620,17 @@
|
|||||||
<div class="commit-actions">
|
<div class="commit-actions">
|
||||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||||
<div class="commit-action-buttons">
|
<div class="commit-action-buttons">
|
||||||
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
|
<button
|
||||||
<GitBranch size={15} aria-hidden="true" />
|
class="commit-menu-button"
|
||||||
Branch
|
type="button"
|
||||||
</button>
|
onclick={(event) => openCommitActionMenu(event, item)}
|
||||||
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Bring this commit's files into your working tree as unstaged changes (no history is changed)">
|
disabled={isBusy}
|
||||||
<RotateCcw size={15} aria-hidden="true" />
|
title="Commit actions"
|
||||||
Restore
|
aria-label={`Actions for ${item.short_hash}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={contextCommit?.hash === item.hash}
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={15} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,6 +639,25 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if contextCommit}
|
||||||
|
<div
|
||||||
|
class="history-context-menu"
|
||||||
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for ${contextCommit.short_hash}`}
|
||||||
|
>
|
||||||
|
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
|
||||||
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
|
Branch
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||||
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if branchDialogOpen}
|
{#if branchDialogOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user