diff --git a/packages/app/src/home/sessions/alt-hold.test.ts b/packages/app/src/home/sessions/alt-hold.test.ts new file mode 100644 index 00000000000..559f3304ee6 --- /dev/null +++ b/packages/app/src/home/sessions/alt-hold.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createAltHold } from "./alt-hold" + +describe("createAltHold", () => { + test("is active only while Alt is held and resets on blur", () => { + let active = () => false + let presses = 0 + const dispose = createRoot((dispose) => { + active = createAltHold(true, () => presses++) + return dispose + }) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt" })) + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt", repeat: true })) + expect(active()).toBe(true) + expect(presses).toBe(1) + + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Alt" })) + expect(active()).toBe(false) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt" })) + window.dispatchEvent(new Event("blur")) + expect(active()).toBe(false) + expect(presses).toBe(2) + dispose() + }) + + test("does nothing outside Desktop", () => { + let presses = 0 + const active = createRoot((dispose) => { + const value = createAltHold(false, () => presses++) + dispose() + return value + }) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt" })) + expect(active()).toBe(false) + expect(presses).toBe(0) + }) +}) diff --git a/packages/app/src/home/sessions/alt-hold.ts b/packages/app/src/home/sessions/alt-hold.ts new file mode 100644 index 00000000000..d81ec8fabac --- /dev/null +++ b/packages/app/src/home/sessions/alt-hold.ts @@ -0,0 +1,19 @@ +import { makeEventListener } from "@solid-primitives/event-listener" +import { createStore } from "solid-js/store" + +export function createAltHold(enabled: boolean, onPress: () => void) { + const [state, setState] = createStore({ active: false }) + if (!enabled) return () => false + + makeEventListener(window, "keydown", (event) => { + if (event.key !== "Alt" || state.active) return + setState("active", true) + onPress() + }) + makeEventListener(window, "keyup", (event) => { + if (event.key === "Alt") setState("active", false) + }) + makeEventListener(window, "blur", () => setState("active", false)) + + return () => state.active +} diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index b1c329d784c..ffa83a61ecf 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -26,7 +26,7 @@ import { sessionLabel, sessionTitle } from "@/session/title" import { showToast } from "@/shell/notifications/toast" import { archiveHomeSession } from "./archive" import type { HomeController } from "../model" -import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records" +import { buildHomeSessionRecords, homeProjectForSession, homeSessionLocation, type HomeSessionRecord } from "./records" export type { HomeSessionRecord } from "./records" @@ -92,6 +92,25 @@ export function createHomeSessionsController(home: HomeController) { const groups = createMemo(() => groupSessions(records(), language)) const prefetched = new Set() + const location = (record: HomeSessionRecord) => { + const branch = home.server.focusedContext()?.data.location.vcs.info(record.session.location)?.branch.current + return homeSessionLocation(record.session.location.directory, branch) + } + + const syncLocations = (record?: HomeSessionRecord) => { + if (platform.platform !== "desktop") return + const ctx = home.server.focusedContext() + if (!ctx) return + if (record) { + void ctx.data.location.vcs.sync(record.session.location).catch(() => undefined) + return + } + const locations = new Map( + records().map((record) => [pathKey(record.session.location.directory), record.session.location] as const), + ) + void Promise.allSettled(Array.from(locations.values(), (location) => ctx.data.location.vcs.sync(location))) + } + createEffect(() => { const ctx = home.server.focusedContext() const conn = home.server.focused() @@ -252,6 +271,13 @@ export function createHomeSessionsController(home: HomeController) { loading: () => sessionLoad.isPending, searchRecords: allRecords, }, + platform: { + desktop: platform.platform === "desktop", + }, + location: { + value: location, + sync: syncLocations, + }, session: { showProjectName: () => !home.project.selected(), server: () => home.selection.value().server, diff --git a/packages/app/src/home/sessions/records.test.ts b/packages/app/src/home/sessions/records.test.ts index bf0bbf4011d..d15bc9724e4 100644 --- a/packages/app/src/home/sessions/records.test.ts +++ b/packages/app/src/home/sessions/records.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { SessionInfo } from "@opencode/client/promise" import type { LocalProject } from "@/shell/state/layout" -import { buildHomeSessionRecords } from "./records" +import { buildHomeSessionRecords, homeSessionLocation } from "./records" const session = (id: string, directory: string, projectID: string) => ({ @@ -59,3 +59,19 @@ describe("buildHomeSessionRecords", () => { expect(records[0]?.project.worktree).toBe("/repo/a/packages/app") }) }) + +describe("homeSessionLocation", () => { + test("returns the worktree directory name and branch", () => { + expect(homeSessionLocation("/repo/.worktrees/crisp-cactus", "feature/home")).toEqual({ + worktree: "crisp-cactus", + branch: "feature/home", + }) + }) + + test("uses the worktree name while branch metadata is unavailable", () => { + expect(homeSessionLocation("/repo/.worktrees/crisp-cactus")).toEqual({ + worktree: "crisp-cactus", + branch: undefined, + }) + }) +}) diff --git a/packages/app/src/home/sessions/records.ts b/packages/app/src/home/sessions/records.ts index bc0c46dea9f..d7528477d93 100644 --- a/packages/app/src/home/sessions/records.ts +++ b/packages/app/src/home/sessions/records.ts @@ -2,6 +2,7 @@ import type { SessionInfo } from "@opencode/client/promise" import type { LocalProject } from "@/shell/state/layout" import { compareSessionTime, displayName } from "@/shell/layout/helpers" import { pathKey } from "@/workspaces/path-key" +import { getFilename } from "@opencode/util/path" export type HomeSessionRecord = { session: SessionInfo @@ -9,6 +10,10 @@ export type HomeSessionRecord = { projectName: string } +export function homeSessionLocation(directory: string, branch?: string) { + return { worktree: getFilename(directory) || directory, branch } +} + export function buildHomeSessionRecords(input: { sessions: () => SessionInfo[] projectDirectories: () => string[] | undefined diff --git a/packages/app/src/home/sessions/region.tsx b/packages/app/src/home/sessions/region.tsx index 1b679d5d019..979fb7fdf96 100644 --- a/packages/app/src/home/sessions/region.tsx +++ b/packages/app/src/home/sessions/region.tsx @@ -16,6 +16,9 @@ export function HomeSessions(props: { language={props.sessions.copy.language} groups={props.sessions.data.groups()} loading={props.sessions.data.loading()} + desktop={props.sessions.platform.desktop} + location={props.sessions.location.value} + onRevealLocations={props.sessions.location.sync} showProjectName={props.sessions.session.showProjectName()} server={server()} canCreateSession={props.sessions.session.canCreate()} diff --git a/packages/app/src/home/sessions/view.tsx b/packages/app/src/home/sessions/view.tsx index 33ba572c8fb..e8a4b6f9eba 100644 --- a/packages/app/src/home/sessions/view.tsx +++ b/packages/app/src/home/sessions/view.tsx @@ -15,6 +15,7 @@ import { ServerConnection } from "@/runtime/server/registry" import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" import { sessionLabel } from "@/session/title" import { shouldOpenSessionInBackground } from "./open" +import { createAltHold } from "./alt-hold" import "./view.css" import { HomeSessionStatusController, @@ -46,6 +47,9 @@ export type HomeSessionsViewProps = { language: ReturnType groups: HomeSessionGroup[] loading: boolean + desktop: boolean + location: (record: HomeSessionRecord) => { worktree: string; branch: string | undefined } + onRevealLocations: (record?: HomeSessionRecord) => void showProjectName: boolean server: ServerConnection.Key canCreateSession: boolean @@ -91,6 +95,8 @@ type HomeSessionRowUI = { export function HomeSessionsView(props: HomeSessionsViewProps) { const [rowUI, setRowUI] = createStore({ menu: undefined, editor: undefined }) + const [hover, setHover] = createStore<{ sessionID: string | undefined }>({ sessionID: undefined }) + const showLocations = createAltHold(props.desktop, props.onRevealLocations) return (
- + setHover("sessionID", sessionID)} + /> 0 && props.canCreateSession}>
@@ -198,12 +219,7 @@ function HomeSessionLeadingController(props: { ) } -function HomeSessionLeading(props: { - record: HomeSessionRecord - open: boolean - unread: boolean - loading: boolean -}) { +function HomeSessionLeading(props: { record: HomeSessionRecord; open: boolean; unread: boolean; loading: boolean }) { return (
@@ -226,7 +242,13 @@ function HomeSessionLeading(props: { ) } -function HomeSessionSearch(props: HomeSessionsViewProps) { +function HomeSessionSearch( + props: HomeSessionsViewProps & { + showLocations: boolean + hoveredSession: string | undefined + onHoverSession: (sessionID: string | undefined) => void + }, +) { return (
@@ -369,9 +391,13 @@ function HomeSessionSearchResultRow( props: HomeSessionsViewProps & { record: HomeSessionRecord selected: boolean + showLocations: boolean + hoveredSession: string | undefined + onHoverSession: (sessionID: string | undefined) => void }, ) { const title = createMemo(() => sessionLabel(props.record.session)) + const location = createMemo(() => props.location(props.record)) const showProjectName = () => props.showProjectName && props.record.projectName const key = () => homeSessionSearchKey(props.record) @@ -385,14 +411,21 @@ function HomeSessionSearchResultRow( role="option" aria-selected={props.selected} class={` - flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left + flex h-10 w-full shrink-0 cursor-default items-center border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none `} classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected, + "gap-1.5": props.desktop, + "gap-2": !props.desktop, }} - onMouseEnter={() => props.onSearchHighlight(props.record)} + onMouseEnter={() => { + props.onSearchHighlight(props.record) + props.onHoverSession(props.record.session.id) + props.onRevealLocations(props.record) + }} + onMouseLeave={() => props.onHoverSession(undefined)} onMouseDown={(event) => { if (event.button === 1) event.preventDefault() }} @@ -403,16 +436,22 @@ function HomeSessionSearchResultRow( props.onSearchSelect(props.record, { background: true }) }} > - +
- + +
) @@ -445,9 +484,13 @@ function HomeSessionRow( record: HomeSessionRecord rowUI: HomeSessionRowUI setRowUI: SetStoreFunction + showLocations: boolean + hoveredSession: string | undefined + onHoverSession: (sessionID: string | undefined) => void }, ) { const title = createMemo(() => sessionLabel(props.record.session)) + const location = createMemo(() => props.location(props.record)) const showProjectName = () => props.showProjectName && props.record.projectName const sessionID = () => props.record.session.id const menu = () => (props.rowUI.menu?.id === sessionID() ? props.rowUI.menu : undefined) @@ -530,13 +573,10 @@ function HomeSessionRow( fallback={
- +
{ @@ -586,14 +626,20 @@ function HomeSessionRow( aria-haspopup="menu" aria-expanded={!!menu()} class={` - flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0 + flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent py-3 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none `} + classList={{ "gap-1.5": props.desktop, "gap-2": !props.desktop }} onMouseDown={(event) => { if (event.button === 1) event.preventDefault() }} + onMouseEnter={() => { + props.onHoverSession(props.record.session.id) + props.onRevealLocations(props.record) + }} + onMouseLeave={() => props.onHoverSession(undefined)} onPointerDown={(event) => { suppressClick = false if (event.pointerType !== "touch") return @@ -644,16 +690,26 @@ function HomeSessionRow( props.onOpenSession(props.record.session, { background: true }) }} > - -
+ +
- + +
@@ -746,13 +802,50 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear ) } -function HomeSessionProjectName(props: { name: string; search?: boolean }) { +function HomeSessionLocation(props: { + location: { worktree: string; branch: string | undefined } + show: boolean + instantHide: boolean + desktop: boolean +}) { + if (!props.desktop) return null + return ( + + + {props.location.worktree} + + {(branch) => ( + <> + + {branch()} + + )} + + + ) +} + +function HomeSessionProjectName(props: { name: string; search?: boolean; trailing?: boolean }) { return ( {props.name}