Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/main/ai/branch-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ export function slugFromModelOutput(text: string, taken: Iterable<string> = []):
// segment ending in '.lock'. Empty segments (from stripped junk) drop out.
slug = slug
.split('/')
.map((s) => s.replace(/(\.lock)+$/, '').replace(/^[-.]+/, '').replace(/[-.]+$/, ''))
.map((s) =>
s
.replace(/(\.lock)+$/, '')
.replace(/^[-.]+/, '')
.replace(/[-.]+$/, '')
)
.filter(Boolean)
.join('/')

Expand Down Expand Up @@ -123,10 +128,7 @@ export interface BranchNameContext {

export async function gatherBranchNameContext(repoPath: string): Promise<BranchNameContext> {
// Both reads are lock-free and independent — overlap them.
const [snapshot, branches] = await Promise.all([
getRepoSnapshot(repoPath),
getBranches(repoPath)
])
const [snapshot, branches] = await Promise.all([getRepoSnapshot(repoPath), getBranches(repoPath)])

const pieces: string[] = []
let budget = BRANCH_NAME_CAPS.totalBytes
Expand Down
12 changes: 10 additions & 2 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
SyncStatus,
UndoSnapshot
} from '@shared/types'
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AboutDialog } from './components/app/AboutDialog'
import { AppModals, type Modal } from './components/app/AppModals'
import { CloneDialog } from './components/app/CloneDialog'
Expand Down Expand Up @@ -392,6 +392,14 @@ export function App() {
resetRange
} = useBranchRange({ getRepoPath, fail, loadRangeDiff, clearDiff })

// The Graph's lit-up container, identified by (name, tip) — a tip hash
// alone is ambiguous: empty branches share it with their anchor's chain.
// Memoized so the canvas' redraw trigger only fires when it truly changes.
const selectedGraphBranch = useMemo(
() => (branchRange ? { name: branchRange.name, tipHash: branchRange.head } : null),
[branchRange]
)

/** Select a commit, dismissing any open branch-changes view. */
const selectCommitOnly = useCallback(
(commit: Commit) => {
Expand Down Expand Up @@ -1609,7 +1617,7 @@ export function App() {
clearDiff()
}
}}
selectedBranchTip={branchRange?.head ?? null}
selectedBranch={selectedGraphBranch}
onSelectBranch={(row) => {
resetDetail()
clearDiff()
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/app/CreateBranchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ export function CreateBranchDialog({
// field greets the user with a name already forming. Only when a backend is
// connected, there are changes to name from, and no caller-provided name.
const autoRan = useRef(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once when the status resolves
useEffect(() => {
if (autoRan.current || !aiStatus || !canSuggest || initialName) return
autoRan.current = true
suggest()
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once when the status resolves
}, [aiStatus])

// The ghost shows while the field is empty: the streaming raw text as it
Expand Down
4 changes: 1 addition & 3 deletions src/renderer/src/components/app/ErrorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ export function ErrorDialog({ message, info, ai, onClose }: Props) {
const [teaserOpen, setTeaserOpen] = useState(false)
const [noteOpen, setNoteOpen] = useState(false)
const explainRef = useRef<HTMLButtonElement>(null)
const explanation = useAiGeneration((e) =>
setFailure(e instanceof Error ? e.message : String(e))
)
const explanation = useAiGeneration((e) => setFailure(e instanceof Error ? e.message : String(e)))

const copy = () => {
window.gitgrove.clipboardWrite(message)
Expand Down
4 changes: 1 addition & 3 deletions src/renderer/src/components/common/AiExplainCommit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ export function useAiExplainCommit(args: AiExplainArgs | null): {
const [failure, setFailure] = useState<string | null>(null)
const [teaserOpen, setTeaserOpen] = useState(false)
const chipRef = useRef<HTMLButtonElement>(null)
const explanation = useAiGeneration((e) =>
setFailure(e instanceof Error ? e.message : String(e))
)
const explanation = useAiGeneration((e) => setFailure(e instanceof Error ? e.message : String(e)))

const hash = args?.hash
// Some hosts remount per commit (keyed by hash), some don't (the Graph's
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/components/common/AiTeaserPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export function AiTeaserPopover({ anchor, open, onClose, title, body, onSetup }:
<Icon.Sparkle size={15} /> {title}
</div>
<p className="ai-teaser__body">
{body} Connect OpenAI, Anthropic, Gemini, a local Ollama or any compatible endpoint —
your key, sent only to your provider.
{body} Connect OpenAI, Anthropic, Gemini, a local Ollama or any compatible endpoint — your
key, sent only to your provider.
</p>
<button
type="button"
Expand Down
26 changes: 15 additions & 11 deletions src/renderer/src/components/graph/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
toWorldY,
type View
} from './geometry'
import type { GraphLayout, GraphNode, GraphRow } from './layout'
import type { BranchSelection, GraphLayout, GraphNode, GraphRow } from './layout'
import { type BackportLink, twinHashes } from './links'
import {
captionMetrics,
Expand Down Expand Up @@ -55,8 +55,10 @@ interface Props {
layout: GraphLayout
theme: 'dark' | 'light'
selectedHash: string | null
/** Tip of the branch whose changes view is open — its container lights up. */
selectedBranchTip: string | null
/** The branch whose changes view is open — its container lights up. Matched
* by (name, tip): empty branches share their tip hash with the anchor's
* chain, so a hash alone would light every one of them (layout.ts). */
selectedBranch: BranchSelection | null
/** Commits kept at full strength while the rest dim; null = no filter. */
matches: ReadonlySet<string> | null
/** The current search hit (louder ring). */
Expand Down Expand Up @@ -141,7 +143,7 @@ export function GraphCanvas({
layout,
theme,
selectedHash,
selectedBranchTip,
selectedBranch,
matches,
activeMatch,
changesCount,
Expand Down Expand Up @@ -174,7 +176,9 @@ export function GraphCanvas({
() =>
changesCount > 0 && headRow
? {
column: layout.columnCount,
// On a zero-commit branch the WIP node docks right of the lane's
// reserved slot; otherwise right of the newest commit column.
column: headRow.empty ? headRow.endColumn + 1 : layout.columnCount,
row: headRow.index,
count: changesCount,
color: headRow.color
Expand All @@ -187,7 +191,7 @@ export function GraphCanvas({
const sceneRef = useRef({
layout,
selectedHash,
selectedBranchTip,
selectedBranch,
matches,
activeMatch,
wip,
Expand All @@ -197,7 +201,7 @@ export function GraphCanvas({
sceneRef.current = {
layout,
selectedHash,
selectedBranchTip,
selectedBranch,
matches,
activeMatch,
wip,
Expand All @@ -223,7 +227,7 @@ export function GraphCanvas({
dpr,
palette: paletteRef.current,
selectedHash: s.selectedHash,
selectedBranchTip: s.selectedBranchTip,
selectedBranch: s.selectedBranch,
hoverHash: hoverRef.current,
matches: s.matches,
activeMatch: s.activeMatch,
Expand All @@ -244,7 +248,7 @@ export function GraphCanvas({
const clampView = useCallback(() => {
const view = viewRef.current
const { width, height } = sizeRef.current
const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip !== null)
const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip?.column ?? null)
const cw = cs.width * view.scale
const ch = cs.height * view.scale
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
Expand Down Expand Up @@ -329,7 +333,7 @@ export function GraphCanvas({
zoomAnim.stop()
panInertia.cancel()
const { width, height } = sizeRef.current
const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip !== null)
const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip?.column ?? null)
const view = viewRef.current
view.scale = Math.min(
MAX_SCALE,
Expand Down Expand Up @@ -434,7 +438,7 @@ export function GraphCanvas({
// biome-ignore lint/correctness/useExhaustiveDependencies: these values aren't read by invalidate — they're the intentional redraw triggers
useEffect(invalidate, [
selectedHash,
selectedBranchTip,
selectedBranch,
matches,
activeMatch,
wip,
Expand Down
17 changes: 12 additions & 5 deletions src/renderer/src/components/graph/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import { Icon } from '@/lib/icons'
import { usePersistentState } from '@/lib/persist'
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas'
import { type AuthorOption, DATE_PRESETS, type DatePresetId, GraphToolbar } from './GraphToolbar'
import { collectBranchNames, type GraphNode, type GraphRow, layoutGraph } from './layout'
import {
type BranchSelection,
collectBranchNames,
type GraphNode,
type GraphRow,
layoutGraph
} from './layout'
import { linkableChains, twinHashes } from './links'
import { relatedBranches } from './related'
import { releaseLineVersion, releaseVersionWithOverride } from './releases'
Expand All @@ -34,8 +40,9 @@ interface Props {
changesCount: number
selectedCommit: Commit | null
onSelectCommit: (commit: Commit | null) => void
/** Tip of the branch whose changes view is open, or null. */
selectedBranchTip: string | null
/** The branch whose changes view is open, or null — matched by (name, tip)
* because empty branches share their tip hash with the anchor's chain. */
selectedBranch: BranchSelection | null
/** A branch label was clicked — open its whole-branch changes view. */
onSelectBranch: (row: GraphRow) => void
/** Right-click menu for a commit node — the same one History uses. */
Expand All @@ -57,7 +64,7 @@ export function GraphView({
changesCount,
selectedCommit,
onSelectCommit,
selectedBranchTip,
selectedBranch,
onSelectBranch,
commitMenuFor,
onCheckoutBranch,
Expand Down Expand Up @@ -369,7 +376,7 @@ export function GraphView({
layout={layout}
theme={theme}
selectedHash={selectedCommit?.hash ?? null}
selectedBranchTip={selectedBranchTip}
selectedBranch={selectedBranch}
matches={matches}
activeMatch={activeMatch}
changesCount={changesCount}
Expand Down
21 changes: 20 additions & 1 deletion src/renderer/src/components/graph/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,25 @@ describe('graph geometry', () => {

test('contentSize reserves a column for the WIP node', () => {
const layout = sampleLayout()
expect(contentSize(layout, true).width - contentSize(layout, false).width).toBe(44)
expect(contentSize(layout, layout.columnCount).width - contentSize(layout, null).width).toBe(44)
})

test('contentSize covers an empty branch slot past the last commit column', () => {
// 'fresh' points at the HEAD tip: zero commits, slot right of the tip.
const layout = layoutGraph({
commits: [commit('b', ['a'], 'HEAD -> main, fresh'), commit('a', [])],
remotes: [],
headBranch: 'main',
detached: false,
defaultBranch: 'main'
})
const fresh = layout.rows.find((r) => r.name === 'fresh')
if (!fresh) throw new Error('missing empty row')
expect(fresh.endColumn).toBe(layout.columnCount)
// One column wider than the commits alone would need.
expect(contentSize(layout, null).width).toBe(
contentSize({ ...layout, rows: layout.rows.filter((r) => r.name === 'main') }, null).width +
44
)
})
})
16 changes: 12 additions & 4 deletions src/renderer/src/components/graph/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,19 @@ export const nodeY = (row: number): number => MARGIN_Y + row * ROW_H + ROW_H / 2
export const toWorldX = (view: View, screenX: number): number => (screenX - view.x) / view.scale
export const toWorldY = (view: View, screenY: number): number => (screenY - view.y) / view.scale

/** World-space size of the whole diagram (an extra column when a WIP node shows). */
export function contentSize(layout: GraphLayout, wip: boolean): { width: number; height: number } {
const columns = layout.columnCount + (wip ? 1 : 0)
/** World-space size of the whole diagram. `wipColumn` is the WIP node's
* column when it shows, or null. Both it and an empty branch's reserved slot
* can sit past the last commit column, so width follows the rightmost of
* commits, row spans and the WIP node. */
export function contentSize(
layout: GraphLayout,
wipColumn: number | null
): { width: number; height: number } {
let last = layout.columnCount - 1
for (const row of layout.rows) last = Math.max(last, row.endColumn)
if (wipColumn !== null) last = Math.max(last, wipColumn)
return {
width: MARGIN_X * 2 + Math.max(1, columns) * COL_W,
width: MARGIN_X * 2 + Math.max(1, last + 1) * COL_W,
height: MARGIN_Y + Math.max(1, layout.rowCount) * ROW_H + ROW_H / 2
}
}
Expand Down
Loading
Loading