feat(ui): support custom workspace names (#522)

## Summary
- Adds editable workspace display names for recent folders.
- Shows the saved workspace name in the recent folder list and instance
tab, including the launching workspace state.
- Preserves workspace names when recent folders are relaunched and keeps
basename fallback for unnamed folders.

Fixes #510

## Validation
- npm run typecheck --workspace @codenomad/ui
- npm run typecheck --workspace @neuralnomads/codenomad
This commit is contained in:
Shantur Rathore 2026-06-04 12:41:05 +01:00 committed by GitHub
parent 9d4c304773
commit 994157f577
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 321 additions and 13 deletions

View file

@ -40,6 +40,7 @@ const PreferencesSchema = z
const RecentFolderSchema = z.object({
path: z.string(),
lastAccessed: z.number().nonnegative(),
projectName: z.string().optional(),
})
const OpenCodeBinarySchema = z.object({

View file

@ -78,6 +78,7 @@ const App: Component = () => {
const { t } = useI18n()
const {
preferences,
recentFolders,
serverSettings,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
@ -265,11 +266,22 @@ const App: Component = () => {
const launchErrorMessage = () => launchError()?.message ?? ""
function getPathBasename(path: string): string {
const normalized = path.replace(/[\\/]+$/, "")
return normalized.split(/[\\/]/).pop() || path
}
function getProjectNameForFolder(folderPath: string): string {
const recent = recentFolders().find((folder) => folder.path === folderPath)
return recent?.projectName?.trim() || getPathBasename(folderPath)
}
async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) {
if (!folderPath) {
return
}
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
const projectName = getProjectNameForFolder(folderPath)
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
@ -283,7 +295,7 @@ const App: Component = () => {
setIsSelectingFolder(true)
try {
const instanceId = await createInstance(folderPath, selectedBinary)
const instanceId = await createInstance(folderPath, selectedBinary, projectName)
selectInstanceTab(instanceId)
setShowFolderSelection(false)

View file

@ -1,10 +1,11 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select"
import { Component, createMemo, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch, Pencil } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import ProjectRenameDialog from "./project-rename-dialog"
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill"
@ -18,7 +19,7 @@ import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window"
import { getExistingInstanceForFolder } from "../stores/instances"
import { getExistingInstanceForFolder, updateProjectNameForFolder } from "../stores/instances"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
@ -38,6 +39,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const {
recentFolders,
removeRecentFolder,
renameRecentFolderProject,
preferences,
updatePreferences,
serverSettings,
@ -53,6 +55,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const [isCloneDialogOpen, setIsCloneDialogOpen] = createSignal(false)
const [isCloneDestinationBrowserOpen, setIsCloneDestinationBrowserOpen] = createSignal(false)
const [renameProjectTarget, setRenameProjectTarget] = createSignal<{ path: string; name: string; label: string } | null>(null)
const [isRenamingProject, setIsRenamingProject] = createSignal(false)
const [cloneRepositoryUrl, setCloneRepositoryUrl] = createSignal("")
const [cloneDestinationPath, setCloneDestinationPath] = createSignal("")
const [cleanupCloneDestination, setCleanupCloneDestination] = createSignal(false)
@ -514,6 +518,35 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
}
function getProjectDisplayName(path: string, projectName?: string): string {
const trimmedName = projectName?.trim()
return trimmedName || splitFolderPath(path).baseName
}
function openProjectRename(path: string, projectName?: string, e?: Event) {
if (isLoading()) return
e?.stopPropagation()
const defaultName = getProjectDisplayName(path, projectName)
setRenameProjectTarget({ path, name: defaultName, label: defaultName })
}
function closeProjectRenameDialog() {
setRenameProjectTarget(null)
}
async function handleProjectRenameSubmit(nextName: string) {
const target = renameProjectTarget()
if (!target || !nextName.trim()) return
setIsRenamingProject(true)
try {
await renameRecentFolderProject(target.path, nextName)
updateProjectNameForFolder(target.path, nextName)
setRenameProjectTarget(null)
} finally {
setIsRenamingProject(false)
}
}
function getDisplayPath(path: string): string {
if (!path) return path
@ -914,7 +947,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<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">
{splitFolderPath(folder.path).baseName}
{getProjectDisplayName(folder.path, folder.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">
@ -934,6 +967,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
</div>
</button>
<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"
title={t("folderSelection.recent.rename")}
>
<Pencil class="w-3.5 h-3.5 transition-colors icon-muted" />
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
@ -1089,6 +1130,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onSelect={handleBrowserSelect}
/>
<ProjectRenameDialog
open={Boolean(renameProjectTarget())}
currentName={renameProjectTarget()?.name ?? ""}
projectLabel={renameProjectTarget()?.label}
isSubmitting={isRenamingProject()}
onRename={handleProjectRenameSubmit}
onClose={closeProjectRenameDialog}
/>
<DirectoryBrowserDialog
open={isCloneDestinationBrowserOpen()}
title={t("folderSelection.clone.destination.title")}

View file

@ -55,6 +55,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
return null
}
})
const tabLabel = createMemo(() => props.instance.projectName?.trim() || getPathBasename(props.instance.folder))
return (
<div class="group">
@ -67,7 +68,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
>
<FolderOpen class="w-4 h-4 flex-shrink-0" />
<span class="tab-label">
{getPathBasename(props.instance.folder)}
{tabLabel()}
</span>
<Show when={statusClassName() && statusTitle()}>
<span

View file

@ -0,0 +1,133 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { useI18n } from "../lib/i18n"
interface ProjectRenameDialogProps {
open: boolean
currentName: string
projectLabel?: string
isSubmitting?: boolean
onRename: (nextName: string) => Promise<void> | void
onClose: () => void
}
const ProjectRenameDialog: Component<ProjectRenameDialogProps> = (props) => {
const { t } = useI18n()
const [name, setName] = createSignal("")
const inputId = `project-rename-${Math.random().toString(36).slice(2)}`
let inputRef: HTMLInputElement | undefined
createEffect(() => {
if (!props.open) return
setName(props.currentName ?? "")
})
createEffect(() => {
if (!props.open) return
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return
window.requestAnimationFrame(() => {
inputRef?.focus()
inputRef?.select()
})
})
const isSubmitting = () => Boolean(props.isSubmitting)
const isRenameDisabled = () => isSubmitting() || !name().trim()
async function handleRename(event?: Event) {
event?.preventDefault()
if (isRenameDisabled()) return
await props.onRename(name().trim())
}
const description = () => {
if (props.projectLabel && props.projectLabel.trim()) {
return t("projectRenameDialog.description.withLabel", { label: props.projectLabel })
}
return t("projectRenameDialog.description.default")
}
return (
<Dialog
open={props.open}
onOpenChange={(open) => {
if (!open && !isSubmitting()) {
props.onClose()
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
<Dialog.Title class="text-lg font-semibold text-primary">{t("projectRenameDialog.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
{description()}
</Dialog.Description>
<form class="mt-4 space-y-4" onSubmit={handleRename}>
<div class="space-y-2">
<label class="text-sm font-medium text-secondary" for={inputId}>
{t("projectRenameDialog.input.label")}
</label>
<input
id={inputId}
ref={(element) => {
inputRef = element
}}
type="text"
dir="auto"
value={name()}
onInput={(event) => setName(event.currentTarget.value)}
placeholder={t("projectRenameDialog.input.placeholder")}
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="button-tertiary"
onClick={() => {
if (!isSubmitting()) {
props.onClose()
}
}}
disabled={isSubmitting()}
>
{t("projectRenameDialog.actions.cancel")}
</button>
<button
type="submit"
class="button-primary flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
disabled={isRenameDisabled()}
>
<Show
when={!isSubmitting()}
fallback={
<>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>{t("projectRenameDialog.actions.renaming")}</span>
</>
}
>
{t("projectRenameDialog.actions.rename")}
</Show>
</button>
</div>
</form>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default ProjectRenameDialog

View file

@ -14,6 +14,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.title": "Recent Folders",
"folderSelection.recent.subtitle.one": "{count} folder available",
"folderSelection.recent.subtitle.other": "{count} folders available",
"folderSelection.recent.rename": "Rename workspace",
"folderSelection.recent.remove": "Remove from recent",
"folderSelection.recent.openBadge": "Open",
"folderSelection.recent.alreadyOpenTitle": "Project already open",
@ -95,4 +96,13 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.cancelLabel": "Cancel",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.",
"folderSelection.sidecars.button": "Open SideCar",
"projectRenameDialog.title": "Rename workspace",
"projectRenameDialog.description.withLabel": "Update the workspace name for \"{label}\".",
"projectRenameDialog.description.default": "Set a workspace name for this folder.",
"projectRenameDialog.input.label": "Workspace name",
"projectRenameDialog.input.placeholder": "Enter workspace name",
"projectRenameDialog.actions.cancel": "Cancel",
"projectRenameDialog.actions.rename": "Rename",
"projectRenameDialog.actions.renaming": "Renaming...",
} as const

View file

@ -14,6 +14,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.title": "Carpetas recientes",
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
"folderSelection.recent.rename": "Renombrar workspace",
"folderSelection.recent.remove": "Eliminar de recientes",
"folderSelection.recent.openBadge": "Abierta",
"folderSelection.recent.alreadyOpenTitle": "Proyecto ya abierto",
@ -95,4 +96,13 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
"folderSelection.sidecars.button": "Open SideCar",
"projectRenameDialog.title": "Renombrar workspace",
"projectRenameDialog.description.withLabel": "Actualiza el nombre del workspace para \"{label}\".",
"projectRenameDialog.description.default": "Define un nombre de workspace para esta carpeta.",
"projectRenameDialog.input.label": "Nombre del workspace",
"projectRenameDialog.input.placeholder": "Introduce el nombre del workspace",
"projectRenameDialog.actions.cancel": "Cancelar",
"projectRenameDialog.actions.rename": "Renombrar",
"projectRenameDialog.actions.renaming": "Renombrando...",
} as const

View file

@ -14,6 +14,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.title": "Dossiers récents",
"folderSelection.recent.subtitle.one": "{count} dossier disponible",
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
"folderSelection.recent.rename": "Renommer l'espace de travail",
"folderSelection.recent.remove": "Retirer des récents",
"folderSelection.recent.openBadge": "Ouvert",
"folderSelection.recent.alreadyOpenTitle": "Projet déjà ouvert",
@ -95,4 +96,13 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
"folderSelection.sidecars.button": "Open SideCar",
"projectRenameDialog.title": "Renommer l'espace de travail",
"projectRenameDialog.description.withLabel": "Mettez à jour le nom de l'espace de travail pour \"{label}\".",
"projectRenameDialog.description.default": "Définissez un nom d'espace de travail pour ce dossier.",
"projectRenameDialog.input.label": "Nom de l'espace de travail",
"projectRenameDialog.input.placeholder": "Saisissez le nom de l'espace de travail",
"projectRenameDialog.actions.cancel": "Annuler",
"projectRenameDialog.actions.rename": "Renommer",
"projectRenameDialog.actions.renaming": "Renommage...",
} as const

View file

@ -14,6 +14,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.title": "תיקיות אחרונות",
"folderSelection.recent.subtitle.one": "{count} תיקייה זמינה",
"folderSelection.recent.subtitle.other": "{count} תיקיות זמינות",
"folderSelection.recent.rename": "שנה שם סביבת עבודה",
"folderSelection.recent.remove": "הסר מהרשימה האחרונה",
"folderSelection.recent.openBadge": "פתוח",
"folderSelection.recent.alreadyOpenTitle": "הפרויקט כבר פתוח",
@ -95,4 +96,13 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
"folderSelection.sidecars.button": "Open SideCar",
"projectRenameDialog.title": "שנה שם סביבת עבודה",
"projectRenameDialog.description.withLabel": "עדכן את שם סביבת העבודה עבור \"{label}\".",
"projectRenameDialog.description.default": "הגדר שם סביבת עבודה לתיקייה זו.",
"projectRenameDialog.input.label": "שם סביבת עבודה",
"projectRenameDialog.input.placeholder": "הזן שם סביבת עבודה",
"projectRenameDialog.actions.cancel": "ביטול",
"projectRenameDialog.actions.rename": "שנה שם",
"projectRenameDialog.actions.renaming": "משנה שם...",
} as const

View file

@ -14,6 +14,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.title": "最近使ったフォルダ",
"folderSelection.recent.subtitle.one": "{count} 件のフォルダ",
"folderSelection.recent.subtitle.other": "{count} 件のフォルダ",
"folderSelection.recent.rename": "ワークスペース名を変更",
"folderSelection.recent.remove": "履歴から削除",
"folderSelection.recent.openBadge": "開いています",
"folderSelection.recent.alreadyOpenTitle": "プロジェクトはすでに開いています",
@ -95,4 +96,13 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
"folderSelection.sidecars.button": "Open SideCar",
"projectRenameDialog.title": "ワークスペース名を変更",
"projectRenameDialog.description.withLabel": "\"{label}\" のワークスペース名を更新します。",
"projectRenameDialog.description.default": "このフォルダのワークスペース名を設定します。",
"projectRenameDialog.input.label": "ワークスペース名",
"projectRenameDialog.input.placeholder": "ワークスペース名を入力",
"projectRenameDialog.actions.cancel": "キャンセル",
"projectRenameDialog.actions.rename": "名前を変更",
"projectRenameDialog.actions.renaming": "変更中...",
} as const

View file

@ -14,6 +14,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.title": "Недавние папки",
"folderSelection.recent.subtitle.one": "Доступна {count} папка",
"folderSelection.recent.subtitle.other": "Доступно {count} папок",
"folderSelection.recent.rename": "Переименовать рабочее пространство",
"folderSelection.recent.remove": "Убрать из недавних",
"folderSelection.recent.openBadge": "Открыта",
"folderSelection.recent.alreadyOpenTitle": "Проект уже открыт",
@ -95,4 +96,13 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.cancelLabel": "Отмена",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.",
"folderSelection.sidecars.button": "Open SideCar",
"projectRenameDialog.title": "Переименовать рабочее пространство",
"projectRenameDialog.description.withLabel": "Обновите имя рабочего пространства для \"{label}\".",
"projectRenameDialog.description.default": "Задайте имя рабочего пространства для этой папки.",
"projectRenameDialog.input.label": "Имя рабочего пространства",
"projectRenameDialog.input.placeholder": "Введите имя рабочего пространства",
"projectRenameDialog.actions.cancel": "Отмена",
"projectRenameDialog.actions.rename": "Переименовать",
"projectRenameDialog.actions.renaming": "Переименование...",
} as const

View file

@ -14,6 +14,7 @@ export const folderSelectionMessages = {
"folderSelection.recent.title": "最近的文件夹",
"folderSelection.recent.subtitle.one": "{count} 个文件夹可用",
"folderSelection.recent.subtitle.other": "{count} 个文件夹可用",
"folderSelection.recent.rename": "重命名工作区",
"folderSelection.recent.remove": "从最近列表移除",
"folderSelection.recent.openBadge": "已打开",
"folderSelection.recent.alreadyOpenTitle": "项目已打开",
@ -95,4 +96,13 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.cancelLabel": "取消",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。",
"folderSelection.sidecars.button": "Open SideCar",
"projectRenameDialog.title": "重命名工作区",
"projectRenameDialog.description.withLabel": "更新“{label}”的工作区名称。",
"projectRenameDialog.description.default": "为此文件夹设置工作区名称。",
"projectRenameDialog.input.label": "工作区名称",
"projectRenameDialog.input.placeholder": "输入工作区名称",
"projectRenameDialog.actions.cancel": "取消",
"projectRenameDialog.actions.rename": "重命名",
"projectRenameDialog.actions.renaming": "正在重命名...",
} as const

View file

@ -108,11 +108,12 @@ const MAX_LOG_ENTRIES = 1000
const pendingDisposeRequests = new Map<string, Promise<boolean>>()
const pendingRehydrations = new Map<string, Promise<void>>()
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor, projectName?: string): Instance {
const existing = instances().get(descriptor.id)
return {
id: descriptor.id,
folder: descriptor.path,
projectName: descriptor.name ?? projectName ?? existing?.projectName,
port: descriptor.port ?? existing?.port ?? 0,
pid: descriptor.pid ?? existing?.pid ?? 0,
proxyPath: descriptor.proxyPath,
@ -142,8 +143,8 @@ function ensureActiveInstanceSelected(): void {
}
}
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
const mapped = workspaceDescriptorToInstance(descriptor)
function upsertWorkspace(descriptor: WorkspaceDescriptor, projectName?: string) {
const mapped = workspaceDescriptorToInstance(descriptor, projectName)
if (instances().has(descriptor.id)) {
updateInstance(descriptor.id, mapped)
} else {
@ -554,10 +555,10 @@ function removeInstance(id: string) {
syncHasInstancesFlag()
}
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
async function createInstance(folder: string, _binaryPath?: string, projectName?: string): Promise<string> {
try {
const workspace = await serverApi.createWorkspace({ path: folder })
upsertWorkspace(workspace)
const workspace = await serverApi.createWorkspace({ path: folder, name: projectName })
upsertWorkspace(workspace, projectName)
setActiveInstanceId(workspace.id)
return workspace.id
} catch (error) {
@ -590,6 +591,18 @@ function getExistingInstanceForFolder(folder: string): Instance | null {
return matches.find((instance) => instance.id === activeId) ?? matches.find((instance) => instance.status === "ready") ?? matches[0] ?? null
}
function updateProjectNameForFolder(folder: string, projectName: string): void {
const name = projectName.trim()
if (!folder || !name) return
const target = normalizeInstanceFolderPath(folder)
for (const instance of instances().values()) {
if (instance.status === "stopped") continue
if (normalizeInstanceFolderPath(instance.folder) === target) {
updateInstance(instance.id, { projectName: name })
}
}
}
async function stopInstance(id: string) {
const instance = instances().get(id)
if (!instance) return
@ -1187,6 +1200,7 @@ export {
removeInstance,
createInstance,
getExistingInstanceForFolder,
updateProjectNameForFolder,
stopInstance,
getActiveInstance,
addLog,

View file

@ -88,6 +88,7 @@ export interface OpenCodeBinary {
export interface RecentFolder {
path: string
lastAccessed: number
projectName?: string
}
export type ThemePreference = "light" | "dark" | "system"
@ -253,9 +254,14 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
if (!f || typeof f !== "object") return null
const p = (f as any).path
const lastAccessed = (f as any).lastAccessed
const projectName = (f as any).projectName
if (typeof p !== "string") return null
const ts = typeof lastAccessed === "number" ? lastAccessed : Date.now()
return { path: p, lastAccessed: ts }
return {
path: p,
lastAccessed: ts,
...(typeof projectName === "string" && projectName.trim() ? { projectName: projectName.trim() } : {}),
}
}),
opencodeBinaries: cloneArray<OpenCodeBinary>(source.opencodeBinaries, (b) => {
if (!b || typeof b !== "object") return null
@ -335,8 +341,13 @@ function getModelKey(model: { providerId: string; modelId: string }): string {
}
function buildRecentFolderList(folderPath: string, source: RecentFolder[]): RecentFolder[] {
const existing = source.find((f) => f.path === folderPath)
const folders = source.filter((f) => f.path !== folderPath)
folders.unshift({ path: folderPath, lastAccessed: Date.now() })
folders.unshift({
path: folderPath,
lastAccessed: Date.now(),
...(existing?.projectName ? { projectName: existing.projectName } : {}),
})
return folders.slice(0, MAX_RECENT_FOLDERS)
}
@ -585,6 +596,18 @@ function removeRecentFolder(folderPath: string): void {
void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to remove recent folder", error))
}
async function renameRecentFolderProject(folderPath: string, projectName: string): Promise<void> {
const name = projectName.trim()
if (!folderPath || !name) return
const next = recentFolders().map((folder) => (folder.path === folderPath ? { ...folder, projectName: name } : folder))
try {
await patchStateOwner("ui", { recentFolders: next })
} catch (error) {
log.error("Failed to rename recent folder", error)
throw error
}
}
async function saveRemoteServerProfile(input: RemoteServerProfileInput): Promise<RemoteServerProfile> {
const profile = buildRemoteServerProfile(input, remoteServers())
await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) })
@ -781,6 +804,7 @@ interface ConfigContextValue {
uiState: typeof uiState
addRecentFolder: typeof addRecentFolder
removeRecentFolder: typeof removeRecentFolder
renameRecentFolderProject: typeof renameRecentFolderProject
addOpenCodeBinary: typeof addOpenCodeBinary
removeOpenCodeBinary: typeof removeOpenCodeBinary
saveRemoteServerProfile: typeof saveRemoteServerProfile
@ -837,6 +861,7 @@ const configContextValue: ConfigContextValue = {
uiState,
addRecentFolder,
removeRecentFolder,
renameRecentFolderProject,
addOpenCodeBinary,
removeOpenCodeBinary,
saveRemoteServerProfile,
@ -924,6 +949,7 @@ export {
updateSpeechSettings,
addRecentFolder,
removeRecentFolder,
renameRecentFolderProject,
addOpenCodeBinary,
removeOpenCodeBinary,
recordWorkspaceLaunch,

View file

@ -33,6 +33,7 @@ export interface InstanceMetadata {
export interface Instance {
id: string
folder: string
projectName?: string
port: number
pid: number
proxyPath: string