feat(app): edit project and session titles
This commit is contained in:
@@ -110,6 +110,7 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
|
|
||||||
function child(directory: string) {
|
function child(directory: string) {
|
||||||
if (!directory) console.error("No directory provided")
|
if (!directory) console.error("No directory provided")
|
||||||
if (!children[directory]) {
|
if (!children[directory]) {
|
||||||
@@ -122,29 +123,33 @@ function createGlobalSync() {
|
|||||||
if (!cache) throw new Error("Failed to create persisted cache")
|
if (!cache) throw new Error("Failed to create persisted cache")
|
||||||
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
|
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
|
||||||
|
|
||||||
children[directory] = createStore<State>({
|
const init = () => {
|
||||||
project: "",
|
children[directory] = createStore<State>({
|
||||||
provider: { all: [], connected: [], default: {} },
|
project: "",
|
||||||
config: {},
|
provider: { all: [], connected: [], default: {} },
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
config: {},
|
||||||
status: "loading" as const,
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
agent: [],
|
status: "loading" as const,
|
||||||
command: [],
|
agent: [],
|
||||||
session: [],
|
command: [],
|
||||||
sessionTotal: 0,
|
session: [],
|
||||||
session_status: {},
|
sessionTotal: 0,
|
||||||
session_diff: {},
|
session_status: {},
|
||||||
todo: {},
|
session_diff: {},
|
||||||
permission: {},
|
todo: {},
|
||||||
question: {},
|
permission: {},
|
||||||
mcp: {},
|
question: {},
|
||||||
lsp: [],
|
mcp: {},
|
||||||
vcs: cache[0].value,
|
lsp: [],
|
||||||
limit: 5,
|
vcs: cache[0].value,
|
||||||
message: {},
|
limit: 5,
|
||||||
part: {},
|
message: {},
|
||||||
})
|
part: {},
|
||||||
bootstrapInstance(directory)
|
})
|
||||||
|
bootstrapInstance(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
runWithOwner(owner, init)
|
||||||
}
|
}
|
||||||
const childStore = children[directory]
|
const childStore = children[directory]
|
||||||
if (!childStore) throw new Error("Failed to create store")
|
if (!childStore) throw new Error("Failed to create store")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
Switch,
|
Switch,
|
||||||
untrack,
|
untrack,
|
||||||
|
type Accessor,
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
@@ -24,6 +25,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
@@ -71,6 +73,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
activeProject: undefined as string | undefined,
|
activeProject: undefined as string | undefined,
|
||||||
activeWorkspace: undefined as string | undefined,
|
activeWorkspace: undefined as string | undefined,
|
||||||
workspaceOrder: {} as Record<string, string[]>,
|
workspaceOrder: {} as Record<string, string[]>,
|
||||||
|
workspaceName: {} as Record<string, string>,
|
||||||
workspaceExpanded: {} as Record<string, boolean>,
|
workspaceExpanded: {} as Record<string, boolean>,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -106,6 +109,104 @@ export default function Layout(props: ParentProps) {
|
|||||||
dark: "Dark",
|
dark: "Dark",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [editor, setEditor] = createStore({
|
||||||
|
active: "" as string,
|
||||||
|
value: "",
|
||||||
|
})
|
||||||
|
const editorRef = { current: undefined as HTMLInputElement | undefined }
|
||||||
|
|
||||||
|
const editorOpen = (id: string) => editor.active === id
|
||||||
|
const editorValue = () => editor.value
|
||||||
|
|
||||||
|
const openEditor = (id: string, value: string) => {
|
||||||
|
if (!id) return
|
||||||
|
setEditor({ active: id, value })
|
||||||
|
queueMicrotask(() => editorRef.current?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEditor = () => setEditor({ active: "", value: "" })
|
||||||
|
|
||||||
|
const saveEditor = (callback: (next: string) => void) => {
|
||||||
|
const next = editor.value.trim()
|
||||||
|
if (!next) {
|
||||||
|
closeEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeEditor()
|
||||||
|
callback(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
saveEditor(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
closeEditor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const InlineEditor = (props: {
|
||||||
|
id: string
|
||||||
|
value: Accessor<string>
|
||||||
|
onSave: (next: string) => void
|
||||||
|
class?: string
|
||||||
|
displayClass?: string
|
||||||
|
editing?: boolean
|
||||||
|
stopPropagation?: boolean
|
||||||
|
openOnDblClick?: boolean
|
||||||
|
}) => {
|
||||||
|
const isEditing = () => props.editing ?? editorOpen(props.id)
|
||||||
|
const stopEvents = () => props.stopPropagation ?? false
|
||||||
|
const allowDblClick = () => props.openOnDblClick ?? true
|
||||||
|
const stopPropagation = (event: Event) => {
|
||||||
|
if (!stopEvents()) return
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
const handleDblClick = (event: MouseEvent) => {
|
||||||
|
if (!allowDblClick()) return
|
||||||
|
stopPropagation(event)
|
||||||
|
openEditor(props.id, props.value())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={isEditing()}
|
||||||
|
fallback={
|
||||||
|
<span
|
||||||
|
class={props.displayClass ?? props.class}
|
||||||
|
onDblClick={handleDblClick}
|
||||||
|
onPointerDown={stopPropagation}
|
||||||
|
onMouseDown={stopPropagation}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
onTouchStart={stopPropagation}
|
||||||
|
>
|
||||||
|
{props.value()}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InlineInput
|
||||||
|
ref={(el) => {
|
||||||
|
editorRef.current = el
|
||||||
|
}}
|
||||||
|
value={editorValue()}
|
||||||
|
class={props.class}
|
||||||
|
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => editorKeyDown(event, props.onSave)}
|
||||||
|
onBlur={() => closeEditor()}
|
||||||
|
onPointerDown={stopPropagation}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
onDblClick={stopPropagation}
|
||||||
|
onMouseDown={stopPropagation}
|
||||||
|
onMouseUp={stopPropagation}
|
||||||
|
onTouchStart={stopPropagation}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function cycleTheme(direction = 1) {
|
function cycleTheme(direction = 1) {
|
||||||
const ids = availableThemeEntries().map(([id]) => id)
|
const ids = availableThemeEntries().map(([id]) => id)
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
@@ -299,6 +400,12 @@ export default function Layout(props: ParentProps) {
|
|||||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const workspaceName = (directory: string) => store.workspaceName[directory]
|
||||||
|
const workspaceLabel = (directory: string, branch?: string) =>
|
||||||
|
workspaceName(directory) ?? branch ?? getFilename(directory)
|
||||||
|
|
||||||
|
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
|
||||||
|
|
||||||
const workspaceSetting = createMemo(() => {
|
const workspaceSetting = createMemo(() => {
|
||||||
const project = currentProject()
|
const project = currentProject()
|
||||||
if (!project) return false
|
if (!project) return false
|
||||||
@@ -700,6 +807,31 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (navigate) navigateToProject(directory)
|
if (navigate) navigateToProject(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = (project: LocalProject) => project.name || getFilename(project.worktree)
|
||||||
|
|
||||||
|
async function renameProject(project: LocalProject, next: string) {
|
||||||
|
if (!project.id) return
|
||||||
|
const current = displayName(project)
|
||||||
|
if (next === current) return
|
||||||
|
const name = next === getFilename(project.worktree) ? "" : next
|
||||||
|
await globalSDK.client.project.update({ projectID: project.id, name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameSession(session: Session, next: string) {
|
||||||
|
if (next === session.title) return
|
||||||
|
await globalSDK.client.session.update({
|
||||||
|
directory: session.directory,
|
||||||
|
sessionID: session.id,
|
||||||
|
title: next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameWorkspace = (directory: string, next: string) => {
|
||||||
|
const current = workspaceName(directory) ?? getFilename(directory)
|
||||||
|
if (current === next) return
|
||||||
|
setStore("workspaceName", directory, next)
|
||||||
|
}
|
||||||
|
|
||||||
function closeProject(directory: string) {
|
function closeProject(directory: string) {
|
||||||
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
|
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
|
||||||
const next = layout.projects.list()[index + 1]
|
const next = layout.projects.list()[index + 1]
|
||||||
@@ -953,9 +1085,14 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
<InlineEditor
|
||||||
{props.session.title}
|
id={`session:${props.session.id}`}
|
||||||
</span>
|
value={() => props.session.title}
|
||||||
|
onSave={(next) => renameSession(props.session, next)}
|
||||||
|
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||||
|
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||||
|
stopPropagation
|
||||||
|
/>
|
||||||
<Show when={props.session.summary}>
|
<Show when={props.session.summary}>
|
||||||
{(summary) => (
|
{(summary) => (
|
||||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||||
@@ -993,6 +1130,174 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ProjectDragOverlay = (): JSX.Element => {
|
||||||
|
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
|
||||||
|
return (
|
||||||
|
<Show when={project()}>
|
||||||
|
{(p) => (
|
||||||
|
<div class="bg-background-base rounded-xl p-1">
|
||||||
|
<ProjectIcon project={p()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceDragOverlay = (): JSX.Element => {
|
||||||
|
const label = createMemo(() => {
|
||||||
|
const project = currentProject()
|
||||||
|
if (!project) return
|
||||||
|
const directory = store.activeWorkspace
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const [workspaceStore] = globalSync.child(directory)
|
||||||
|
const kind = directory === project.worktree ? "local" : "sandbox"
|
||||||
|
const name = workspaceLabel(directory, workspaceStore.vcs?.branch)
|
||||||
|
return `${kind} : ${name}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={label()}>
|
||||||
|
{(value) => (
|
||||||
|
<div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
|
const sortable = createSortable(props.directory)
|
||||||
|
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
|
||||||
|
const slug = createMemo(() => base64Encode(props.directory))
|
||||||
|
const sessions = createMemo(() =>
|
||||||
|
workspaceStore.session
|
||||||
|
.filter((session) => session.directory === workspaceStore.path.directory)
|
||||||
|
.filter((session) => !session.parentID)
|
||||||
|
.toSorted(sortSessions),
|
||||||
|
)
|
||||||
|
const local = createMemo(() => props.directory === props.project.worktree)
|
||||||
|
const workspaceValue = createMemo(() => {
|
||||||
|
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
|
||||||
|
return workspaceName(props.directory) ?? name
|
||||||
|
})
|
||||||
|
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
|
||||||
|
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
|
||||||
|
const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (!local()) return
|
||||||
|
setWorkspaceStore("limit", (limit) => limit + 5)
|
||||||
|
await globalSync.project.loadSessions(props.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`))
|
||||||
|
|
||||||
|
const openWrapper = (value: boolean) => {
|
||||||
|
setStore("workspaceExpanded", props.directory, value)
|
||||||
|
if (value) return
|
||||||
|
if (editorOpen(`workspace:${props.directory}`)) closeEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
|
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
||||||
|
<div class="px-2 py-1">
|
||||||
|
<div class="group/trigger relative">
|
||||||
|
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||||
|
<div class="flex items-center gap-1 min-w-0">
|
||||||
|
<div class="flex items-center justify-center shrink-0 size-6">
|
||||||
|
<Icon name="branch" size="small" />
|
||||||
|
</div>
|
||||||
|
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
|
||||||
|
<Show
|
||||||
|
when={!local()}
|
||||||
|
fallback={
|
||||||
|
<span class="text-14-medium text-text-base">
|
||||||
|
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InlineEditor
|
||||||
|
id={`workspace:${props.directory}`}
|
||||||
|
value={workspaceValue}
|
||||||
|
onSave={(next) => {
|
||||||
|
const trimmed = next.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
renameWorkspace(props.directory, trimmed)
|
||||||
|
setEditor("value", workspaceValue())
|
||||||
|
}}
|
||||||
|
class="text-14-medium text-text-base"
|
||||||
|
displayClass="text-14-medium text-text-base"
|
||||||
|
editing={workspaceEditActive()}
|
||||||
|
stopPropagation={false}
|
||||||
|
openOnDblClick={false}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Icon
|
||||||
|
name={open() ? "chevron-down" : "chevron-right"}
|
||||||
|
size="small"
|
||||||
|
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
|
||||||
|
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
|
||||||
|
<TooltipKeybind
|
||||||
|
class="pointer-events-auto"
|
||||||
|
placement="right"
|
||||||
|
title="New session"
|
||||||
|
keybind={command.keybind("session.new")}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md"
|
||||||
|
onClick={() => navigate(`/${slug()}/session`)}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<nav class="flex flex-col gap-1 px-2">
|
||||||
|
<Button
|
||||||
|
as={A}
|
||||||
|
href={`${slug()}/session`}
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
icon="edit"
|
||||||
|
class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3"
|
||||||
|
>
|
||||||
|
New session
|
||||||
|
</Button>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<SessionSkeleton />
|
||||||
|
</Show>
|
||||||
|
<For each={sessions()}>
|
||||||
|
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
|
||||||
|
</For>
|
||||||
|
<Show when={hasMore()}>
|
||||||
|
<div class="relative w-full py-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
||||||
|
size="large"
|
||||||
|
onClick={(e: MouseEvent) => {
|
||||||
|
loadMore()
|
||||||
|
;(e.currentTarget as HTMLButtonElement).blur()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const selected = createMemo(() => {
|
const selected = createMemo(() => {
|
||||||
@@ -1005,7 +1310,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const label = (directory: string) => {
|
const label = (directory: string) => {
|
||||||
const [data] = globalSync.child(directory)
|
const [data] = globalSync.child(directory)
|
||||||
const kind = directory === props.project.worktree ? "local" : "sandbox"
|
const kind = directory === props.project.worktree ? "local" : "sandbox"
|
||||||
const name = data.vcs?.branch ?? getFilename(directory)
|
const name = workspaceLabel(directory, data.vcs?.branch)
|
||||||
return `${kind} : ${name}`
|
return `${kind} : ${name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,148 +1408,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectDragOverlay = (): JSX.Element => {
|
|
||||||
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
|
|
||||||
return (
|
|
||||||
<Show when={project()}>
|
|
||||||
{(p) => (
|
|
||||||
<div class="bg-background-base rounded-xl p-1">
|
|
||||||
<ProjectIcon project={p()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceDragOverlay = (): JSX.Element => {
|
|
||||||
const label = createMemo(() => {
|
|
||||||
const project = currentProject()
|
|
||||||
if (!project) return
|
|
||||||
const directory = store.activeWorkspace
|
|
||||||
if (!directory) return
|
|
||||||
|
|
||||||
const [workspaceStore] = globalSync.child(directory)
|
|
||||||
const kind = directory === project.worktree ? "local" : "sandbox"
|
|
||||||
const name = workspaceStore.vcs?.branch ?? getFilename(directory)
|
|
||||||
return `${kind} : ${name}`
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={label()}>
|
|
||||||
{(value) => (
|
|
||||||
<div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
|
|
||||||
const sortable = createSortable(props.directory)
|
|
||||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
|
|
||||||
const slug = createMemo(() => base64Encode(props.directory))
|
|
||||||
const sessions = createMemo(() =>
|
|
||||||
workspaceStore.session
|
|
||||||
.filter((session) => session.directory === workspaceStore.path.directory)
|
|
||||||
.filter((session) => !session.parentID)
|
|
||||||
.toSorted(sortSessions),
|
|
||||||
)
|
|
||||||
const local = createMemo(() => props.directory === props.project.worktree)
|
|
||||||
const title = createMemo(() => {
|
|
||||||
const kind = local() ? "local" : "sandbox"
|
|
||||||
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
|
|
||||||
return `${kind} : ${name}`
|
|
||||||
})
|
|
||||||
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
|
|
||||||
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
|
|
||||||
const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
|
|
||||||
const loadMore = async () => {
|
|
||||||
if (!local()) return
|
|
||||||
setWorkspaceStore("limit", (limit) => limit + 5)
|
|
||||||
await globalSync.project.loadSessions(props.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// @ts-ignore
|
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
|
||||||
<Collapsible
|
|
||||||
variant="ghost"
|
|
||||||
open={open()}
|
|
||||||
class="shrink-0"
|
|
||||||
onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)}
|
|
||||||
>
|
|
||||||
<div class="px-2 py-1">
|
|
||||||
<div class="group/trigger relative">
|
|
||||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-all group-hover/trigger:pr-16 group-focus-within/trigger:pr-16">
|
|
||||||
<div class="flex items-center gap-1 min-w-0">
|
|
||||||
<div class="flex items-center justify-center shrink-0 size-6">
|
|
||||||
<Icon name="branch" size="small" />
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-14-medium text-text-base">{title()}</span>
|
|
||||||
<Icon
|
|
||||||
name={open() ? "chevron-down" : "chevron-right"}
|
|
||||||
size="small"
|
|
||||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
|
|
||||||
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
|
|
||||||
<TooltipKeybind
|
|
||||||
class="pointer-events-auto"
|
|
||||||
placement="right"
|
|
||||||
title="New session"
|
|
||||||
keybind={command.keybind("session.new")}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="plus-small"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md"
|
|
||||||
onClick={() => navigate(`/${slug()}/session`)}
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Collapsible.Content>
|
|
||||||
<nav class="flex flex-col gap-1 px-2">
|
|
||||||
<Button
|
|
||||||
as={A}
|
|
||||||
href={`${slug()}/session`}
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
icon="edit"
|
|
||||||
class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3"
|
|
||||||
>
|
|
||||||
New session
|
|
||||||
</Button>
|
|
||||||
<Show when={loading()}>
|
|
||||||
<SessionSkeleton />
|
|
||||||
</Show>
|
|
||||||
<For each={sessions()}>
|
|
||||||
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
|
|
||||||
</For>
|
|
||||||
<Show when={hasMore()}>
|
|
||||||
<div class="relative w-full py-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
|
||||||
size="large"
|
|
||||||
onClick={(e: MouseEvent) => {
|
|
||||||
loadMore()
|
|
||||||
;(e.currentTarget as HTMLButtonElement).blur()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</nav>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree)
|
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree)
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
@@ -1306,6 +1469,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!current) return ""
|
if (!current) return ""
|
||||||
return current.name || getFilename(current.worktree)
|
return current.name || getFilename(current.worktree)
|
||||||
})
|
})
|
||||||
|
const projectId = createMemo(() => project()?.id ?? "")
|
||||||
const workspaces = createMemo(() => workspaceIds(project()))
|
const workspaces = createMemo(() => workspaceIds(project()))
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
@@ -1406,13 +1570,22 @@ export default function Layout(props: ParentProps) {
|
|||||||
<div class="shrink-0 px-2 py-1">
|
<div class="shrink-0 px-2 py-1">
|
||||||
<div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
|
<div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<span class="text-16-medium text-text-strong truncate">{projectName()}</span>
|
<InlineEditor
|
||||||
|
id={`project:${projectId()}`}
|
||||||
|
value={projectName}
|
||||||
|
onSave={(next) => project() && renameProject(project()!, next)}
|
||||||
|
class="text-16-medium text-text-strong truncate"
|
||||||
|
displayClass="text-16-medium text-text-strong truncate"
|
||||||
|
stopPropagation
|
||||||
|
/>
|
||||||
|
|
||||||
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
|
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
|
||||||
<span class="text-12-regular text-text-base truncate">
|
<span class="text-12-regular text-text-base truncate">
|
||||||
{project()?.worktree.replace(homedir(), "~")}
|
{project()?.worktree.replace(homedir(), "~")}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
@@ -1469,7 +1642,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
New workspace
|
New workspace
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0">
|
<div class="relative flex-1 min-h-0">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={handleWorkspaceDragStart}
|
onDragStart={handleWorkspaceDragStart}
|
||||||
onDragEnd={handleWorkspaceDragEnd}
|
onDragEnd={handleWorkspaceDragEnd}
|
||||||
|
|||||||
17
packages/ui/src/components/inline-input.css
Normal file
17
packages/ui/src/components/inline-input.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[data-component="inline-input"] {
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-interactive-focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/ui/src/components/inline-input.tsx
Normal file
11
packages/ui/src/components/inline-input.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ComponentProps } from "solid-js"
|
||||||
|
import { splitProps } from "solid-js"
|
||||||
|
|
||||||
|
export type InlineInputProps = ComponentProps<"input"> & {
|
||||||
|
width?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineInput(props: InlineInputProps) {
|
||||||
|
const [local, others] = splitProps(props, ["class", "width"])
|
||||||
|
return <input data-component="inline-input" class={local.class} style={{ width: local.width }} {...others} />
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
@import "../components/icon-button.css" layer(components);
|
@import "../components/icon-button.css" layer(components);
|
||||||
@import "../components/image-preview.css" layer(components);
|
@import "../components/image-preview.css" layer(components);
|
||||||
@import "../components/text-field.css" layer(components);
|
@import "../components/text-field.css" layer(components);
|
||||||
|
@import "../components/inline-input.css" layer(components);
|
||||||
@import "../components/list.css" layer(components);
|
@import "../components/list.css" layer(components);
|
||||||
@import "../components/logo.css" layer(components);
|
@import "../components/logo.css" layer(components);
|
||||||
@import "../components/markdown.css" layer(components);
|
@import "../components/markdown.css" layer(components);
|
||||||
|
|||||||
Reference in New Issue
Block a user