mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-21 08:07:34 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a947caa2 | ||
|
|
298c863fae |
@@ -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,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,
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user