From 5537f26311474f6566d1743af61a10fef9f525c7 Mon Sep 17 00:00:00 2001 From: Caleb Owens Date: Tue, 29 Jul 2025 16:34:28 +0200 Subject: [PATCH] Keyboard navigation WIP --- apps/desktop/src/components/BranchList.svelte | 346 +++++++++--------- .../desktop/src/components/FocusCursor.svelte | 116 ++++++ .../src/components/MainViewport.svelte | 2 +- apps/desktop/src/components/StackView.svelte | 12 +- .../src/lib/focus/focusManager.svelte.ts | 60 ++- apps/desktop/src/routes/+layout.svelte | 7 + .../src/routes/[projectId]/+layout.svelte | 4 - 7 files changed, 365 insertions(+), 182 deletions(-) create mode 100644 apps/desktop/src/components/FocusCursor.svelte diff --git a/apps/desktop/src/components/BranchList.svelte b/apps/desktop/src/components/BranchList.svelte index 6b3ec645ea..0dc013ff5e 100644 --- a/apps/desktop/src/components/BranchList.svelte +++ b/apps/desktop/src/components/BranchList.svelte @@ -15,6 +15,8 @@ import { getColorFromCommitState, getIconFromCommitState } from '$components/lib'; import { STACKING_REORDER_DROPZONE_MANAGER_FACTORY } from '$lib/dragging/stackingReorderDropzoneManager'; import { editPatch } from '$lib/editMode/editPatchUtils'; + import { branchFocusableId, stackFocusableId } from '$lib/focus/focusManager.svelte'; + import { focusable } from '$lib/focus/focusable.svelte'; import { DEFAULT_FORGE_FACTORY } from '$lib/forge/forgeFactory.svelte'; import { INTELLIGENT_SCROLLING_SERVICE } from '$lib/intelligentScrolling/service'; import { MODE_SERVICE } from '$lib/mode/modeService'; @@ -138,190 +140,196 @@ : undefined} {@const first = i === 0} - - - {#snippet children([localAndRemoteCommits, upstreamOnlyCommits, branchDetails, commit])} - {@const firstBranch = i === 0} - {@const lastBranch = i === branches.length - 1} - {@const iconName = getIconFromCommitState(commit?.id, commit?.state)} - {@const lineColor = commit - ? getColorFromCommitState( - commit.state.type, - commit.state.type === 'LocalAndRemote' && commit.id !== commit.state.subject - ) - : 'var(--clr-commit-local)'} - {@const isNewBranch = - upstreamOnlyCommits.length === 0 && localAndRemoteCommits.length === 0} - {@const selected = - selection?.current?.branchName === branchName && - selection?.current.commitId === undefined} - {@const pushStatus = branchDetails.pushStatus} - {@const isConflicted = branchDetails.isConflicted} - {@const lastUpdatedAt = branchDetails.lastUpdatedAt} - {@const reviewId = branch.reviewId || undefined} - {@const prNumber = branch.prNumber || undefined} - { - uiState.stack(stackId).selection.set({ branchName }); - intelligentScrollingService.show(projectId, stackId, 'details'); - onselect?.(); - }} - > - {#snippet buttons()} - - {:else} - {@const prUrl = prResult?.current.data?.htmlUrl} + + /> {/if} - {/if} - 1} - isFirstBranchInStack={firstBranch} - isLastBranchInStack={lastBranch} - /> - {/snippet} - {#snippet menu({ rightClickTrigger })} - {@const data = { - branch, - prNumber, - first, - stackLength: branches.length - }} - (headerMenuContext = { data, position: { element } })} - oncontext={(coords) => (headerMenuContext = { data, position: { coords } })} - contextElementSelected={selected} - activated={branchName === headerMenuContext?.data.branch.name && - !!headerMenuContext.position.element} - /> - {/snippet} + {#if canPublishPR && !isNewBranch} + {#if !branch.prNumber} + + {:else} + {@const prUrl = prResult?.current.data?.htmlUrl} + + {/if} + {/if} + 1} + isFirstBranchInStack={firstBranch} + isLastBranchInStack={lastBranch} + /> + {/snippet} - {#snippet branchContent()} - - {/snippet} - - {/snippet} - + {#snippet menu({ rightClickTrigger })} + {@const data = { + branch, + prNumber, + first, + stackLength: branches.length + }} + (headerMenuContext = { data, position: { element } })} + oncontext={(coords) => (headerMenuContext = { data, position: { coords } })} + contextElementSelected={selected} + activated={branchName === headerMenuContext?.data.branch.name && + !!headerMenuContext.position.element} + /> + {/snippet} + + {#snippet branchContent()} + + {/snippet} + + {/snippet} + + {/each} diff --git a/apps/desktop/src/components/FocusCursor.svelte b/apps/desktop/src/components/FocusCursor.svelte new file mode 100644 index 0000000000..d8883f0b0b --- /dev/null +++ b/apps/desktop/src/components/FocusCursor.svelte @@ -0,0 +1,116 @@ + + +{#if target} +
+{/if} + + diff --git a/apps/desktop/src/components/MainViewport.svelte b/apps/desktop/src/components/MainViewport.svelte index 7cbc73d20e..44c22b357c 100644 --- a/apps/desktop/src/components/MainViewport.svelte +++ b/apps/desktop/src/components/MainViewport.svelte @@ -173,7 +173,7 @@ the window, then enlarge it and retain the original widths of the layout. style:width={finalPreviewWidth + 'rem'} style:min-width={previewMinWidth + 'rem'} use:focusable={{ - id: DefinedFocusable.ViewportMiddle, + id: DefinedFocusable.ViewportLeftExpanded, parentId: DefinedFocusable.MainViewport }} > diff --git a/apps/desktop/src/components/StackView.svelte b/apps/desktop/src/components/StackView.svelte index a693c4cca3..d51d339fcb 100644 --- a/apps/desktop/src/components/StackView.svelte +++ b/apps/desktop/src/components/StackView.svelte @@ -12,7 +12,11 @@ import SelectionView from '$components/SelectionView.svelte'; import WorktreeChanges from '$components/WorktreeChanges.svelte'; import { isParsedError } from '$lib/error/parser'; - import { DefinedFocusable } from '$lib/focus/focusManager.svelte'; + import { + DefinedFocusable, + newCommitAndAssignedFilesId, + stackFocusableId + } from '$lib/focus/focusManager.svelte'; import { focusable } from '$lib/focus/focusable.svelte'; import { DIFF_SERVICE } from '$lib/hunks/diffService.svelte'; import { @@ -380,7 +384,7 @@ } }} use:focusable={{ - id: DefinedFocusable.Stack + ':' + stack.id, + id: stackFocusableId(stack.id), parentId: DefinedFocusable.ViewportMiddle }} > @@ -414,6 +418,10 @@
diff --git a/apps/desktop/src/lib/focus/focusManager.svelte.ts b/apps/desktop/src/lib/focus/focusManager.svelte.ts index 70052b95e3..206b6d8ff1 100644 --- a/apps/desktop/src/lib/focus/focusManager.svelte.ts +++ b/apps/desktop/src/lib/focus/focusManager.svelte.ts @@ -9,6 +9,7 @@ export const FOCUS_MANAGER = new InjectionToken('FocusManager'); export enum DefinedFocusable { MainViewport = 'workspace', ViewportLeft = 'workspace-left', + ViewportLeftExpanded = 'workspace-left-expanded', ViewportRight = 'workspace-right', ViewportDrawerRight = 'workspace-drawer-right', ViewportMiddle = 'workspace-middle', @@ -17,8 +18,9 @@ export enum DefinedFocusable { Branches = 'branches', Stack = 'stack', Preview = 'preview', - // Only one of these can be in the dom at any given time. - ChangedFiles = 'changed-files' + ChangedFiles = 'changed-files', + NewCommitAndAssignedFiles = 'new-commit-and-assigned-files', + Branch = 'branch' } export function stackFocusableId(stackId: string) { @@ -29,6 +31,14 @@ export function uncommittedFocusableId(stackId?: string) { return `${DefinedFocusable.UncommittedChanges}:${stackId}`; } +export function newCommitAndAssignedFilesId(stackId: string) { + return `${DefinedFocusable.NewCommitAndAssignedFiles}:${stackId}`; +} + +export function branchFocusableId(stackId: string, branchName: string) { + return `${DefinedFocusable.Branch}:${stackId}:${branchName}`; +} + /** * If the provided ID is a assigned-changes id, it will return the stackId as a * string, otherwise, it will return undefined. @@ -50,6 +60,8 @@ export type FocusableElement = { children: Focusable[]; }; +type Cursor = { setTarget(element: HTMLElement): void; show(): void }; + /** * Manages focusable areas through the `focusable` svelte action. * @@ -81,26 +93,42 @@ export class FocusManager implements Reactive { private handleMouse = this.handleClick.bind(this); private handleKeys = this.handleKeydown.bind(this); + private cursor: Cursor | undefined; constructor() { $effect(() => { // We listen for events on the document in the bubble phase, giving // other event handlers an opportunity to stop propagation. + console.log('I should happen _once_'); return mergeUnlisten( on(document, 'click', this.handleMouse), - on(document, 'keypress', this.handleKeys) + on(document, 'keydown', this.handleKeys) ); }); } + setCursor(cursor: Cursor): () => void { + if (this.cursor) { + throw new Error('There should only ever be one instance of the focus cursor'); + } + + this.cursor = cursor; + + return () => { + this.cursor = undefined; + }; + } + get current() { return this._current; } handleClick(e: Event) { + console.log('handling click'); if (e.target instanceof HTMLElement) { let pointer: HTMLElement | null = e.target; while (pointer) { + console.log('looping...'); const item = this.lookup.get(pointer); if (item) { this.setActive(item.key); @@ -152,6 +180,10 @@ export class FocusManager implements Reactive { setActive(id: Focusable) { this._current = id; + const element = this.elements.find((e) => e.key === id); + if (element) { + this.cursor?.setTarget(element.element); + } } focusSibling(forward = true) { @@ -169,7 +201,10 @@ export class FocusManager implements Reactive { const nextIndex = (index + (forward ? 1 : siblings.length - 1)) % siblings.length; this.setActive(siblings[nextIndex]!); - this.elements.find((a) => a.key === siblings[nextIndex])?.element.focus(); + const element = this.elements.find((a) => a.key === siblings[nextIndex])?.element; + element?.focus(); + element?.scrollIntoView(); + this.cursor?.show(); } focusParent() { @@ -179,11 +214,15 @@ export class FocusManager implements Reactive { const area = this.elements.find((a) => a.key === currentId); if (area?.parentId) { this.setActive(area.parentId); - this.elements.find((a) => a.key === area.parentId)?.element.focus(); + const element = this.elements.find((a) => a.key === area.parentId)?.element; + element?.focus(); + element?.scrollIntoView(); + this.cursor?.show(); } } focusFirstChild() { + console.log('focused first child'); const currentId = this._current; if (!currentId) return; @@ -191,7 +230,10 @@ export class FocusManager implements Reactive { if (area && area.children.length > 0) { const firstChild = area.children[0]; this.setActive(firstChild!); - this.elements.find((a) => a.key === firstChild)?.element.focus(); + const element = this.elements.find((a) => a.key === firstChild)?.element; + element?.focus(); + element?.scrollIntoView(); + this.cursor?.show(); } } @@ -205,6 +247,12 @@ export class FocusManager implements Reactive { } else if (event.metaKey && event.key === 'ArrowDown') { event.preventDefault(); this.focusFirstChild(); + } else if (event.metaKey && event.key === 'ArrowLeft') { + event.preventDefault(); + this.focusSibling(false); + } else if (event.metaKey && event.key === 'ArrowRight') { + event.preventDefault(); + this.focusSibling(true); } } diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index 8a03acfa05..8c98194c7e 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { page } from '$app/state'; import AppUpdater from '$components/AppUpdater.svelte'; + import FocusCursor from '$components/FocusCursor.svelte'; import GlobalModal from '$components/GlobalModal.svelte'; import GlobalSettingsMenuAction from '$components/GlobalSettingsMenuAction.svelte'; import PromptModal from '$components/PromptModal.svelte'; @@ -35,6 +36,7 @@ import { DropzoneRegistry, DROPZONE_REGISTRY } from '$lib/dragging/registry'; import FeedFactory, { FEED_FACTORY } from '$lib/feed/feed'; import { FILE_SERVICE } from '$lib/files/fileService'; + import { FOCUS_MANAGER, FocusManager } from '$lib/focus/focusManager.svelte'; import { DefaultForgeFactory, DEFAULT_FORGE_FACTORY } from '$lib/forge/forgeFactory.svelte'; import { GitHubClient, GITHUB_CLIENT } from '$lib/forge/github/githubClient'; import { @@ -273,6 +275,9 @@ provide(RESIZE_SYNC, new ResizeSync()); provide(GIT_SERVICE, new GitService(data.tauri)); + const focusManager = new FocusManager(); + provide(FOCUS_MANAGER, focusManager); + const settingsService = data.settingsService; const settingsStore = settingsService.appSettings; @@ -356,6 +361,8 @@ {/if} + +