Compare commits

...
Author SHA1 Message Date
usrnk1 37a947caa2 test(desktop): cover session metadata reveal 2026-09-18 16:28:01 +02:00
usrnk1 298c863fae feat(desktop): reveal session workspace metadata 2026-09-18 16:28:01 +02:00
7 changed files with 240 additions and 37 deletions
@@ -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)
})
})
@@ -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
}
+27 -1
View File
@@ -27,7 +27,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"
@@ -93,6 +93,25 @@ export function createHomeSessionsController(home: HomeController) {
const groups = createMemo(() => groupSessions(records(), language))
const prefetched = new Set<string>()
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()
@@ -253,6 +272,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,
+17 -1
View File
@@ -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,
})
})
})
@@ -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
@@ -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()}
+128 -35
View File
@@ -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<typeof useLanguage>
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<HomeSessionRowUI>({ menu: undefined, editor: undefined })
const [hover, setHover] = createStore<{ sessionID: string | undefined }>({ sessionID: undefined })
const showLocations = createAltHold(props.desktop, props.onRevealLocations)
return (
<section
ref={props.onSetHoverTarget}
@@ -101,7 +107,12 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-1 pt-6 md:pb-3 lg:pt-12"
onWheel={props.onWheel}
>
<HomeSessionSearch {...props} />
<HomeSessionSearch
{...props}
showLocations={showLocations()}
hoveredSession={hover.sessionID}
onHoverSession={(sessionID) => setHover("sessionID", sessionID)}
/>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
<Button
@@ -162,7 +173,17 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
rows would be disposed mid-interaction whenever a
sync response lands. */}
<Key each={group().sessions} by={(record) => record.session.id}>
{(record) => <HomeSessionRow {...props} record={record()} rowUI={rowUI} setRowUI={setRowUI} />}
{(record) => (
<HomeSessionRow
{...props}
record={record()}
rowUI={rowUI}
setRowUI={setRowUI}
showLocations={showLocations()}
hoveredSession={hover.sessionID}
onHoverSession={(sessionID) => setHover("sessionID", sessionID)}
/>
)}
</Key>
</div>
</>
@@ -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 (
<div class="relative shrink-0">
<Show when={props.open}>
@@ -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 (
<div class="w-full">
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
@@ -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 })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
/>
<HomeSessionLeadingController server={props.server} isOpenTab={props.isOpenTab} record={props.record} />
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} search />
<HomeSessionProjectName name={props.record.projectName} trailing={props.desktop} search />
</Show>
<HomeSessionLocation
location={location()}
show={props.showLocations || props.hoveredSession === props.record.session.id}
instantHide={
!props.showLocations &&
props.hoveredSession !== undefined &&
props.hoveredSession !== props.record.session.id
}
desktop={props.desktop}
/>
</div>
</button>
)
@@ -445,9 +484,13 @@ function HomeSessionRow(
record: HomeSessionRecord
rowUI: HomeSessionRowUI
setRowUI: SetStoreFunction<HomeSessionRowUI>
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={
<div
data-slot="home-session-editor"
class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
class="flex h-10 min-w-0 w-full flex-1 items-center py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
classList={{ "gap-1.5": props.desktop, "gap-2": !props.desktop }}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
/>
<HomeSessionLeadingController server={props.server} isOpenTab={props.isOpenTab} record={props.record} />
<div data-slot="home-session-labels" class="contents">
<InlineInput
data-component="home-session-rename"
@@ -547,7 +587,7 @@ function HomeSessionRow(
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
@@ -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 })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
/>
<div data-slot="home-session-labels" class="contents">
<HomeSessionLeadingController server={props.server} isOpenTab={props.isOpenTab} record={props.record} />
<div
data-slot="home-session-labels"
class="min-w-0 flex-1 items-center gap-1.5"
classList={{ flex: props.desktop, contents: !props.desktop }}
>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
<HomeSessionProjectName name={props.record.projectName} trailing={props.desktop} />
</Show>
<HomeSessionLocation
location={location()}
show={props.showLocations || props.hoveredSession === props.record.session.id}
instantHide={
!props.showLocations &&
props.hoveredSession !== undefined &&
props.hoveredSession !== props.record.session.id
}
desktop={props.desktop}
/>
</div>
</button>
</Show>
@@ -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 (
<span
dir="ltr"
aria-hidden={!props.show || undefined}
class="flex min-w-0 shrink-0 items-center gap-1.5 overflow-hidden whitespace-nowrap ease-out motion-reduce:transition-none"
classList={{
"-ms-1.5 max-w-0 opacity-0": !props.show,
"max-w-[60%] opacity-100": props.show,
"transition-[max-width,opacity] duration-[120ms]": !props.instantHide,
"transition-none": props.instantHide,
}}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 overflow-hidden text-ellipsis [font-weight:440]">{props.location.worktree}</span>
<Show when={props.location.branch}>
{(branch) => (
<>
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 overflow-hidden text-ellipsis [font-weight:440]">{branch()}</span>
</>
)}
</Show>
</span>
)
}
function HomeSessionProjectName(props: { name: string; search?: boolean; trailing?: boolean }) {
return (
<span
data-component="home-session-project-name"
dir="auto"
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{
"flex-[0_1_auto]": !!props.trailing,
"flex-[1_1_auto]": !props.trailing,
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
}}
>
{props.name}
</span>