Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 12 additions & 60 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import {
import { recentFolders, useConfig } from "./stores/preferences"
import {
createInstance,
getExistingInstanceForFolder,
instances,
stopInstance,
disconnectedInstance,
Expand Down Expand Up @@ -102,11 +101,6 @@ const App: Component = () => {
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
const [alreadyOpenFolderChoice, setAlreadyOpenFolderChoice] = createSignal<{
folderPath: string
binaryPath: string
instanceId: string
} | null>(null)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())

Expand Down Expand Up @@ -288,25 +282,17 @@ const App: Component = () => {
const projectName = getProjectNameForFolder(folderPath)
clearLaunchError()

if (!options?.forceNew) {
const existingInstance = getExistingInstanceForFolder(folderPath)
if (existingInstance) {
recordWorkspaceLaunch(existingInstance.folder, selectedBinary, folderPath)
setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id })
return
}
}

setIsSelectingFolder(true)
try {
const result = await createInstance(folderPath, selectedBinary, projectName, { forceNew: options?.forceNew })
recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
if (result.reused) {
recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: result.instanceId })
selectInstanceTab(result.instanceId)
setShowFolderSelection(false)
log.info("Selected reused instance", { instanceId: result.instanceId, folderPath })
return
}

recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
selectInstanceTab(result.instanceId)
setShowFolderSelection(false)

Expand All @@ -324,24 +310,13 @@ const App: Component = () => {
}
}

function dismissAlreadyOpenFolderChoice() {
setAlreadyOpenFolderChoice(null)
}

function switchToAlreadyOpenFolder() {
const choice = alreadyOpenFolderChoice()
if (!choice) return
setAlreadyOpenFolderChoice(null)
selectInstanceTab(choice.instanceId)
function handleSelectExistingInstance(instanceId: string, recentPath: string, binaryPath: string) {
const instance = instances().get(instanceId)
if (!instance) return
recordWorkspaceLaunch(instance.folder, binaryPath, recentPath)
selectInstanceTab(instanceId)
setShowFolderSelection(false)
log.info("Selected existing instance", { instanceId: choice.instanceId, folderPath: choice.folderPath })
}

function openAnotherFolderInstance() {
const choice = alreadyOpenFolderChoice()
if (!choice) return
setAlreadyOpenFolderChoice(null)
void handleSelectFolder(choice.folderPath, choice.binaryPath, { forceNew: true })
log.info("Selected existing instance", { instanceId, folderPath: instance.folder })
}

function handleLaunchErrorClose() {
Expand Down Expand Up @@ -655,6 +630,7 @@ const App: Component = () => {
>
<FolderSelectionView
onSelectFolder={handleSelectFolder}
onSelectExistingInstance={handleSelectExistingInstance}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
/>
Expand All @@ -665,6 +641,7 @@ const App: Component = () => {
<div class="w-full h-full relative">
<FolderSelectionView
onSelectFolder={handleSelectFolder}
onSelectExistingInstance={handleSelectExistingInstance}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
onClose={() => {
Expand All @@ -678,31 +655,6 @@ const App: Component = () => {

<SettingsScreen />
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
<Show when={alreadyOpenFolderChoice()}>
<Dialog open modal onOpenChange={(open) => !open && dismissAlreadyOpenFolderChoice()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay z-[60]" />
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
<Dialog.Title class="text-lg font-semibold text-primary">
{t("folderSelection.recent.alreadyOpenTitle")}
</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
{t("folderSelection.recent.alreadyOpenMessage")}
</Dialog.Description>

<div class="mt-6 flex justify-end gap-3">
<button type="button" class="button-secondary" onClick={openAnotherFolderInstance}>
{t("folderSelection.recent.openAnotherInstance")}
</button>
<button type="button" class="button-primary" onClick={switchToAlreadyOpenFolder}>
{t("folderSelection.recent.switchToOpenProject")}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</Show>

<AlertDialog />

<Toaster
Expand Down
130 changes: 105 additions & 25 deletions packages/ui/src/components/folder-selection-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type HomeTab = "local" | "servers"

interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string, options?: { forceNew?: boolean }) => void
onSelectExistingInstance: (instanceId: string, recentPath: string, binaryPath: string) => void
onOpenSidecar?: () => void
isLoading?: boolean
onClose?: () => void
Expand All @@ -50,6 +51,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [hoveredRecentActionPath, setHoveredRecentActionPath] = createSignal<string | null>(null)
const [focusedRecentActionPath, setFocusedRecentActionPath] = createSignal<string | null>(null)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
Expand Down Expand Up @@ -133,6 +136,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const isEditingField =
activeElement &&
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal))
const isInteractiveControl = activeElement && ["BUTTON", "A"].includes(activeElement.tagName)

if (isEditingField) {
return
Expand All @@ -155,6 +159,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return
}

if (isInteractiveControl && (e.key === "Enter" || e.key === " ")) return

const listLength = getActiveListLength()
if (listLength === 0) return

Expand Down Expand Up @@ -209,7 +215,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (activeTab() === "local") {
const folder = folders()[index]
if (folder) {
handleFolderSelect(folder.path)
handleFolderSelect(folder.path, true)
}
return
}
Expand Down Expand Up @@ -309,9 +315,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return t("time.relative.justNow")
}

function handleFolderSelect(path: string) {
function handleFolderSelect(path: string, forceNew = false) {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary(), forceNew ? { forceNew: true } : undefined)
}

function handleExistingInstanceSelect(instanceId: string, recentPath: string) {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary())
props.onSelectExistingInstance(instanceId, recentPath, selectedBinary())
}

function setRecentActionHovered(path: string, active: boolean) {
setHoveredRecentActionPath((current) => active ? path : current === path ? null : current)
}

function setRecentActionFocused(path: string, active: boolean) {
setFocusedRecentActionPath((current) => active ? path : current === path ? null : current)
}

function clearRecentActionState(path: string) {
setRecentActionHovered(path, false)
setRecentActionFocused(path, false)
}

function resetCloneDialog() {
Expand Down Expand Up @@ -924,37 +948,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<For each={folders()}>
{(folder, index) => {
const existingInstance = () => getExistingInstanceForFolder(folder.path)
const projectName = () => getProjectDisplayName(folder.path, folder.projectName)
const projectLabelId = () => `recent-folder-${index()}-name`
const openActionLabelId = () => `recent-folder-${index()}-open-action`

return <div
class="panel-list-item"
class="panel-list-item folder-home-recent-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
"folder-home-recent-item-action-active":
hoveredRecentActionPath() === folder.path || focusedRecentActionPath() === folder.path,
}}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="panel-list-item-content relative flex-1">
<button
data-list-index={index()}
type="button"
class="folder-home-recent-primary-action"
disabled={isLoading()}
aria-labelledby={projectLabelId()}
onClick={() => handleFolderSelect(folder.path, true)}
onFocus={() => {
setFocusMode("recent")
setSelectedIndex(index())
}}
/>
<div class="relative z-[1] pointer-events-none flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{getProjectDisplayName(folder.path, folder.projectName)}
<span id={projectLabelId()} class="text-sm font-medium truncate text-primary">
{projectName()}
</span>
<Show when={existingInstance()}>
<span class="rounded-full border border-base px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-secondary">
{t("folderSelection.recent.openBadge")}
</span>
{(instance) => {
let ownsHoverState = false
let ownsFocusState = false
onCleanup(() => {
if (ownsHoverState) setRecentActionHovered(folder.path, false)
if (ownsFocusState) setRecentActionFocused(folder.path, false)
})
return (
<button
type="button"
class="folder-home-open-instance-button folder-home-row-action pointer-events-auto"
disabled={isLoading()}
aria-labelledby={`${openActionLabelId()} ${projectLabelId()}`}
title={t("folderSelection.recent.switchToOpenProject")}
onClick={() => handleExistingInstanceSelect(instance().id, folder.path)}
onMouseEnter={() => {
ownsHoverState = true
setRecentActionHovered(folder.path, true)
}}
onMouseLeave={() => {
ownsHoverState = false
setRecentActionHovered(folder.path, false)
}}
onFocus={() => {
ownsFocusState = true
setRecentActionFocused(folder.path, true)
}}
onBlur={() => {
ownsFocusState = false
setRecentActionFocused(folder.path, false)
}}
>
<span id={openActionLabelId()}>{t("folderSelection.recent.openBadge")}</span>
</button>
)
}}
</Show>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
Expand All @@ -968,20 +1037,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<kbd class="kbd">↵</kbd>
</Show>
</div>
</button>
</div>
<button
onClick={(e) => openProjectRename(folder.path, folder.projectName, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-surface-hover opacity-70 hover:opacity-100 rounded"
class="folder-home-row-action p-2 transition-all hover:bg-surface-hover opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.rename")}
onMouseEnter={() => setRecentActionHovered(folder.path, true)}
onMouseLeave={() => setRecentActionHovered(folder.path, false)}
onFocus={() => setRecentActionFocused(folder.path, true)}
onBlur={() => setRecentActionFocused(folder.path, false)}
>
<Pencil class="w-3.5 h-3.5 transition-colors icon-muted" />
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
onClick={(e) => {
clearRecentActionState(folder.path)
handleRemove(folder.path, e)
}}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
class="folder-home-row-action p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
onMouseEnter={() => setRecentActionHovered(folder.path, true)}
onMouseLeave={() => setRecentActionHovered(folder.path, false)}
onFocus={() => setRecentActionFocused(folder.path, true)}
onBlur={() => setRecentActionFocused(folder.path, false)}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/src/lib/i18n/messages/de/folderSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.rename": "Arbeitsbereich umbenennen",
"folderSelection.recent.remove": "Aus Liste entfernen",
"folderSelection.recent.openBadge": "Offen",
"folderSelection.recent.alreadyOpenTitle": "Projekt bereits offen",
"folderSelection.recent.alreadyOpenMessage": "Wählen Sie, wie dieser Ordner geöffnet werden soll.",
"folderSelection.recent.switchToOpenProject": "Zum offenen Projekt wechseln",
"folderSelection.recent.openAnotherInstance": "Weitere Instanz öffnen",

"folderSelection.browse.title": "Nach Ordner suchen",
"folderSelection.browse.subtitle": "Wählen Sie einen beliebigen Ordner auf Ihrem Computer aus",
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/src/lib/i18n/messages/en/folderSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.rename": "Rename workspace",
"folderSelection.recent.remove": "Remove from recent",
"folderSelection.recent.openBadge": "Open",
"folderSelection.recent.alreadyOpenTitle": "Project already open",
"folderSelection.recent.alreadyOpenMessage": "Choose how to open this folder.",
"folderSelection.recent.switchToOpenProject": "Switch to open project",
"folderSelection.recent.openAnotherInstance": "Open another instance",

"folderSelection.browse.title": "Browse for Folder",
"folderSelection.browse.subtitle": "Select any folder on your computer",
Expand Down
Loading
Loading