diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts
index 7c2a65d..55868a0 100644
--- a/playwright/github-byot-ai.spec.ts
+++ b/playwright/github-byot-ai.spec.ts
@@ -303,16 +303,103 @@ test('chat becomes available after token connect', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible()
})
-test('workspace context status is visible only after PAT connect', async ({ page }) => {
+test('workspace context status stays visible without PAT and after PAT connect', async ({
+ page,
+}) => {
await waitForAppReady(page)
const workspaceContextStatus = page.locator('#workspace-context-status')
- await expect(workspaceContextStatus).toBeHidden()
+ await expect(workspaceContextStatus).toBeVisible()
+ await expect(workspaceContextStatus).toContainText('local')
await connectByotWithSingleRepo(page)
await expect(workspaceContextStatus).toBeVisible()
})
+test('Local workspace can be renamed from Workspaces drawer', async ({ page }) => {
+ const sourceWorkspaceId = 'local_workspace_rename_source'
+ const targetWorkspaceId = 'local_workspace_rename_target'
+ const originalTitle = 'Local rename original title'
+ const renamedTitle = 'Local rename updated title'
+
+ await waitForAppReady(page)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: sourceWorkspaceId,
+ repo: '',
+ workspaceScope: 'local',
+ head: 'feat/local-rename-source',
+ prTitle: originalTitle,
+ prContextState: 'inactive',
+ tabs: [
+ {
+ id: 'component',
+ path: 'src/component.tsx',
+ language: 'tsx',
+ role: 'component',
+ content: 'export const App = () => rename source',
+ order: 0,
+ source: 'workspace',
+ dirty: false,
+ },
+ ],
+ activeTabId: 'component',
+ },
+ {
+ id: targetWorkspaceId,
+ repo: '',
+ workspaceScope: 'local',
+ head: 'feat/local-rename-target',
+ prTitle: 'Local rename target title',
+ prContextState: 'inactive',
+ tabs: [
+ {
+ id: 'component',
+ path: 'src/component.tsx',
+ language: 'tsx',
+ role: 'component',
+ content: 'export const App = () => rename target',
+ order: 0,
+ source: 'workspace',
+ dirty: false,
+ },
+ ],
+ activeTabId: 'component',
+ },
+ ])
+
+ const workspacesToggle = page.getByRole('button', {
+ name: 'Workspaces',
+ exact: true,
+ })
+ await workspacesToggle.click()
+
+ const workspaceSelect = page.getByLabel('Stored workspace')
+ const renameButton = page.getByRole('button', { name: 'Rename', exact: true })
+
+ await workspaceSelect.selectOption(sourceWorkspaceId)
+ await expect(workspaceSelect).toHaveValue(sourceWorkspaceId)
+ await expect(renameButton).toBeEnabled()
+
+ page.once('dialog', async dialog => {
+ expect(dialog.type()).toBe('prompt')
+ expect(dialog.defaultValue()).toBe(originalTitle)
+ await dialog.accept(renamedTitle)
+ })
+
+ await renameButton.click()
+ await expect(page.locator('#workspaces-status')).toContainText('Renamed workspace.')
+
+ const records = await getAllWorkspaceRecords(page)
+ const renamedRecord = records.find(record => record?.id === sourceWorkspaceId)
+
+ expect(renamedRecord).toBeTruthy()
+ expect(typeof renamedRecord?.prTitle === 'string' ? renamedRecord.prTitle : '').toBe(
+ renamedTitle,
+ )
+})
+
test('BYOT controls render with default app entry', async ({ page }) => {
await waitForAppReady(page, appEntryPath)
diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts
index 64c59ad..47fd39d 100644
--- a/playwright/github-pr-drawer/open-pr-create.spec.ts
+++ b/playwright/github-pr-drawer/open-pr-create.spec.ts
@@ -934,7 +934,7 @@ test('Workspaces repository selector filters contexts and keeps local-only conte
await selectWorkspacesRepositoryFilter(page, '__local__')
const localLabels = await getLocalContextOptionLabels(page)
expect(localLabels).toContain('Select a stored workspace')
- expect(localLabels).toContain('local:Alpha local context')
+ expect(localLabels).toContain('Alpha local context')
expect(localLabels).not.toContain('Alpha active context')
})
@@ -1312,7 +1312,7 @@ test('Switching Workspaces repository scope to Local keeps inactive record repo
await expect
.poll(async () => {
const localLabels = await getLocalContextOptionLabels(page)
- return localLabels.includes('local:feat/component-v8zw')
+ return localLabels.includes('feat/component-v8zw')
})
.toBe(true)
diff --git a/src/app.js b/src/app.js
index fb1155d..3f1a7be 100644
--- a/src/app.js
+++ b/src/app.js
@@ -150,6 +150,7 @@ const workspacesInitialize = document.getElementById('workspaces-initialize')
const workspacesNew = document.getElementById('workspaces-new')
const workspacesSelect = document.getElementById('workspaces-select')
const workspacesOpen = document.getElementById('workspaces-open')
+const workspacesRename = document.getElementById('workspaces-rename')
const workspacesRemove = document.getElementById('workspaces-remove')
const componentPrSyncIcon = document.getElementById('component-pr-sync-icon')
const componentPrSyncIconPath = document.getElementById('component-pr-sync-icon-path')
@@ -425,6 +426,8 @@ let workspacePrContextState = 'inactive'
let workspacePrNumber = null
let workspaceRepositoryFullName = ''
let workspaceScopeMarker = 'local'
+let activeWorkspacePersistedPrTitle = ''
+let activeWorkspacePersistedHeadBranch = ''
let hasObservedActivePrContextInSession = false
let workspaceContextStatusController = {
render: () => {},
@@ -450,6 +453,8 @@ const toPullRequestNumber = value => {
const setActiveWorkspaceRecordId = nextValue => {
activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue)
if (!activeWorkspaceRecordId) {
+ activeWorkspacePersistedPrTitle = ''
+ activeWorkspacePersistedHeadBranch = ''
workspaceRepositoryFullName = ''
workspaceScopeMarker = 'local'
}
@@ -607,6 +612,8 @@ workspaceContextStatusController = createWorkspaceContextStatusController({
toNonEmptyWorkspaceText,
getWorkspacePrTitle: () => githubPrTitle?.value,
getWorkspaceHeadBranch: () => githubPrHeadBranch?.value,
+ getActiveWorkspacePersistedPrTitle: () => activeWorkspacePersistedPrTitle,
+ getActiveWorkspacePersistedHeadBranch: () => activeWorkspacePersistedHeadBranch,
getWorkspaceScopeMarker: () => workspaceScopeMarker,
getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName,
@@ -787,6 +794,15 @@ const onWorkspaceRecordApplied = createWorkspaceRecordAppliedHandler({
getStyleModeValue: () => styleMode.value,
})
+const onWorkspaceRecordAppliedWithStatusMetadata = workspace => {
+ if (workspace && typeof workspace === 'object') {
+ activeWorkspacePersistedPrTitle = toNonEmptyWorkspaceText(workspace.prTitle)
+ activeWorkspacePersistedHeadBranch = toNonEmptyWorkspaceText(workspace.head)
+ }
+
+ onWorkspaceRecordApplied(workspace)
+}
+
const {
workspaceSaveController,
listLocalContextRecords,
@@ -878,7 +894,7 @@ const {
getWorkspaceTabByKind,
makeUniqueTabPath,
createWorkspaceTabId,
- onWorkspaceRecordApplied,
+ onWorkspaceRecordApplied: onWorkspaceRecordAppliedWithStatusMetadata,
})
const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } =
@@ -1116,6 +1132,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({
workspacesNew,
workspacesSelect,
workspacesOpen,
+ workspacesRename,
workspacesRemove,
},
workspace: {
diff --git a/src/index.html b/src/index.html
index 2119a49..9fd480d 100644
--- a/src/index.html
+++ b/src/index.html
@@ -350,7 +350,6 @@
Workspaces
+
diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js
index 14592c9..ec55a0f 100644
--- a/src/modules/app-core/github-workflows.js
+++ b/src/modules/app-core/github-workflows.js
@@ -46,6 +46,7 @@ const initializeGitHubWorkflows = ({
workspacesNew,
workspacesSelect,
workspacesOpen,
+ workspacesRename,
workspacesRemove,
workspaceStorage,
getActiveWorkspaceRecordId,
@@ -121,6 +122,8 @@ const initializeGitHubWorkflows = ({
const toSafeRepositoryFullName = value =>
typeof value === 'string' ? value.trim() : ''
+ const toSafeWorkspaceText = value => (typeof value === 'string' ? value.trim() : '')
+
const shouldApplyActivePrEditorSync = ({ repository, activeContext }) => {
const syncedContextKey = getActivePrContextSyncKey(activeContext)
const currentSyncKey = getActivePrEditorSyncKey()
@@ -425,6 +428,7 @@ const initializeGitHubWorkflows = ({
newButton: workspacesNew,
selectInput: workspacesSelect,
openButton: workspacesOpen,
+ renameButton: workspacesRename,
removeButton: workspacesRemove,
getRepositoryFilterOptions: () =>
getCurrentWritableRepositories().map(repository => ({
@@ -527,6 +531,81 @@ const initializeGitHubWorkflows = ({
return false
}
},
+ onRenameSelected: async workspaceId => {
+ try {
+ const record = await workspaceStorage.getWorkspaceById(workspaceId)
+ if (!record) {
+ await refreshLocalContextOptions()
+ workspacesDrawerController?.setStatus(
+ 'Stored workspace no longer exists.',
+ 'error',
+ )
+ return false
+ }
+
+ const workspaceScope = toSafeWorkspaceText(record.workspaceScope).toLowerCase()
+ const isLocalWorkspace =
+ workspaceScope === 'local' ||
+ (!workspaceScope && !toSafeWorkspaceText(record.repo))
+ if (!isLocalWorkspace) {
+ workspacesDrawerController?.setStatus(
+ 'Only Local workspaces can be renamed here.',
+ 'error',
+ )
+ return false
+ }
+
+ if (getActiveWorkspaceRecordId() === workspaceId) {
+ workspacesDrawerController?.setStatus(
+ 'Open a different workspace before renaming this one.',
+ 'error',
+ )
+ return false
+ }
+
+ const currentWorkspaceName =
+ toSafeWorkspaceText(record.prTitle) ||
+ toSafeWorkspaceText(record.head) ||
+ toSafeWorkspaceText(record.id) ||
+ 'workspace'
+ const promptedWorkspaceName = window.prompt(
+ 'Rename local workspace',
+ currentWorkspaceName,
+ )
+
+ if (promptedWorkspaceName === null) {
+ return false
+ }
+
+ const nextWorkspaceName = promptedWorkspaceName.trim()
+ if (!nextWorkspaceName) {
+ workspacesDrawerController?.setStatus(
+ 'Workspace name cannot be empty.',
+ 'error',
+ )
+ return false
+ }
+
+ if (nextWorkspaceName === currentWorkspaceName) {
+ return false
+ }
+
+ await workspaceStorage.upsertWorkspace({
+ ...record,
+ prTitle: nextWorkspaceName,
+ lastModified: Date.now(),
+ })
+
+ await refreshLocalContextOptions()
+ return true
+ } catch {
+ workspacesDrawerController?.setStatus(
+ 'Could not rename stored workspace.',
+ 'error',
+ )
+ return false
+ }
+ },
onRemoveSelected: async workspaceId => {
confirmAction({
title: 'Remove stored workspace?',
diff --git a/src/modules/app-core/workspace-context-status-controller.js b/src/modules/app-core/workspace-context-status-controller.js
index 1c976a8..dc493f3 100644
--- a/src/modules/app-core/workspace-context-status-controller.js
+++ b/src/modules/app-core/workspace-context-status-controller.js
@@ -1,21 +1,35 @@
-const hasTokenValue = token => typeof token === 'string' && token.trim().length > 0
-
const createWorkspaceContextStatusController = ({
statusNode,
toNonEmptyWorkspaceText,
getWorkspacePrTitle,
getWorkspaceHeadBranch,
+ getActiveWorkspacePersistedPrTitle,
+ getActiveWorkspacePersistedHeadBranch,
getWorkspaceScopeMarker,
getActiveWorkspaceRecordId,
getWorkspaceRepositoryFullName,
getSelectedRepositoryFullName,
}) => {
- let hasValidatedGitHubPat = false
- let hasCompletedRepositoryLoad = false
- const appGrid =
- statusNode instanceof HTMLElement ? statusNode.closest('.app-grid') : null
-
const getWorkspaceName = () => {
+ const workspaceScope =
+ toNonEmptyWorkspaceText(getWorkspaceScopeMarker?.()).toLowerCase() || 'local'
+
+ if (workspaceScope === 'local') {
+ const persistedPrTitle = toNonEmptyWorkspaceText(
+ getActiveWorkspacePersistedPrTitle?.(),
+ )
+ if (persistedPrTitle) {
+ return persistedPrTitle
+ }
+
+ const persistedHeadBranch = toNonEmptyWorkspaceText(
+ getActiveWorkspacePersistedHeadBranch?.(),
+ )
+ if (persistedHeadBranch) {
+ return persistedHeadBranch
+ }
+ }
+
const prTitle = toNonEmptyWorkspaceText(getWorkspacePrTitle?.())
if (prTitle) {
return prTitle
@@ -34,17 +48,7 @@ const createWorkspaceContextStatusController = ({
return
}
- if (appGrid instanceof HTMLElement) {
- appGrid.classList.toggle(
- 'app-grid--workspace-context-visible',
- hasValidatedGitHubPat,
- )
- }
-
- statusNode.toggleAttribute('hidden', !hasValidatedGitHubPat)
- if (!hasValidatedGitHubPat) {
- return
- }
+ statusNode.removeAttribute('hidden')
const workspaceName = getWorkspaceName()
const workspaceScope =
@@ -64,25 +68,13 @@ const createWorkspaceContextStatusController = ({
}
const syncTokenState = token => {
- if (!hasTokenValue(token)) {
- hasValidatedGitHubPat = false
- hasCompletedRepositoryLoad = false
- } else if (hasCompletedRepositoryLoad) {
- hasValidatedGitHubPat = true
- }
-
+ void token
render()
}
const syncWritableRepositoriesState = ({ token, isLoadingRepositories = false }) => {
- if (!isLoadingRepositories) {
- hasCompletedRepositoryLoad = true
- }
-
- if (hasTokenValue(token) && !isLoadingRepositories) {
- hasValidatedGitHubPat = true
- }
-
+ void token
+ void isLoadingRepositories
render()
}
diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js
index 339ef1a..08f985e 100644
--- a/src/modules/workspace/workspaces-drawer/drawer.js
+++ b/src/modules/workspace/workspaces-drawer/drawer.js
@@ -26,22 +26,18 @@ const toSafeWorkspaceScope = workspace => {
: localWorkspaceScopeValue
}
-const toWorkspaceLabel = (workspace, { forceLocalPrefix = false } = {}) => {
- const isLocalScoped =
- forceLocalPrefix || toSafeWorkspaceScope(workspace) === localWorkspaceScopeValue
-
+const toWorkspaceLabel = workspace => {
const hasTitle = toSafeText(workspace?.prTitle)
if (hasTitle) {
- return isLocalScoped ? `local:${hasTitle}` : hasTitle
+ return hasTitle
}
const hasHead = toSafeText(workspace?.head)
if (hasHead) {
- return isLocalScoped ? `local:${hasHead}` : hasHead
+ return hasHead
}
- const fallbackLabel = toSafeText(workspace?.id) || 'workspace'
- return isLocalScoped ? `local:${fallbackLabel}` : fallbackLabel
+ return toSafeText(workspace?.id) || 'workspace'
}
export const createWorkspacesDrawer = ({
@@ -55,6 +51,7 @@ export const createWorkspacesDrawer = ({
newButton,
selectInput,
openButton,
+ renameButton,
removeButton,
getDrawerSide,
getRepositoryFilterOptions,
@@ -64,6 +61,7 @@ export const createWorkspacesDrawer = ({
onInitializeWorkspace,
onCreateWorkspace,
onOpenSelected,
+ onRenameSelected,
onRemoveSelected,
} = {}) => {
let open = false
@@ -152,15 +150,28 @@ export const createWorkspacesDrawer = ({
const activeWorkspaceId =
typeof getActiveWorkspaceId === 'function' ? toSafeText(getActiveWorkspaceId()) : ''
const hasSelection = normalizedSelectedId.length > 0
+ const selectedEntry = entries.find(
+ entry => toSafeText(entry?.id) === normalizedSelectedId,
+ )
+ const selectedWorkspaceScope = toSafeWorkspaceScope(selectedEntry)
+ const isSelectedLocalWorkspace =
+ hasSelection && selectedWorkspaceScope === localWorkspaceScopeValue
const isSelectedWorkspaceActive =
hasSelection &&
Boolean(activeWorkspaceId) &&
normalizedSelectedId === activeWorkspaceId
+ const canRenameWorkspace =
+ typeof onRenameSelected === 'function' &&
+ isSelectedLocalWorkspace &&
+ !isSelectedWorkspaceActive
const canCreateWorkspace = typeof onCreateWorkspace === 'function'
const canInitializeWorkspace = typeof onInitializeWorkspace === 'function'
const hasStoredWorkspaces =
currentUiState === drawerUiState.localWithWorkspaces ||
currentUiState === drawerUiState.repositoryWithWorkspaces
+ const isLocalUiState =
+ currentUiState === drawerUiState.localWithWorkspaces ||
+ currentUiState === drawerUiState.localEmpty
const showInitialize = currentUiState === drawerUiState.repositoryEmpty
const showNewWorkspace = !showInitialize
@@ -190,6 +201,11 @@ export const createWorkspacesDrawer = ({
openButton.disabled = !hasSelection
}
+ if (renameButton instanceof HTMLButtonElement) {
+ renameButton.toggleAttribute('hidden', !isLocalUiState)
+ renameButton.disabled = !canRenameWorkspace
+ }
+
if (removeButton instanceof HTMLButtonElement) {
removeButton.disabled = !hasSelection || isSelectedWorkspaceActive
}
@@ -229,12 +245,7 @@ export const createWorkspacesDrawer = ({
for (const entry of filteredEntries) {
const option = document.createElement('option')
option.value = toSafeText(entry.id)
- const shouldPrefixAsLocal =
- normalizedRepositoryFilter === localRepositoryFilterValue &&
- shouldRenderAsLocalEntry(entry)
- option.textContent = toWorkspaceLabel(entry, {
- forceLocalPrefix: shouldPrefixAsLocal,
- })
+ option.textContent = toWorkspaceLabel(entry)
option.selected = option.value === selectedId
selectInput.append(option)
}
@@ -521,6 +532,30 @@ export const createWorkspacesDrawer = ({
setStatus('Loaded workspace.', 'neutral')
})
+ renameButton?.addEventListener('click', async () => {
+ const id = toSafeText(selectedId)
+ if (!id || typeof onRenameSelected !== 'function') {
+ return
+ }
+
+ selectedId = id
+
+ let renamed = false
+ try {
+ renamed = await onRenameSelected(id)
+ } catch {
+ setStatus('Could not rename stored workspace.', 'error')
+ return
+ }
+
+ if (!renamed) {
+ return
+ }
+
+ await refresh({ preserveSelection: true })
+ setStatus('Renamed workspace.', 'neutral')
+ })
+
removeButton?.addEventListener('click', async () => {
const id = toSafeText(selectedId)
if (!id || typeof onRemoveSelected !== 'function') {
diff --git a/src/styles/layout-shell.css b/src/styles/layout-shell.css
index 2b5a292..fa1983c 100644
--- a/src/styles/layout-shell.css
+++ b/src/styles/layout-shell.css
@@ -110,10 +110,11 @@
--expanded-panel-min-height: 360px;
display: grid;
grid-template-columns: repeat(2, minmax(320px, 1fr));
- grid-template-rows: auto auto minmax(0, 1fr);
+ grid-template-rows: auto auto auto minmax(0, 1fr);
grid-template-areas:
'layout-controls layout-controls'
'workspace-tabs workspace-tabs'
+ 'workspace-context workspace-context'
'editors preview';
gap: 18px;
padding: 24px;
@@ -122,15 +123,6 @@
overflow: hidden;
}
-.app-grid.app-grid--workspace-context-visible {
- grid-template-rows: auto auto auto minmax(0, 1fr);
- grid-template-areas:
- 'layout-controls layout-controls'
- 'workspace-tabs workspace-tabs'
- 'workspace-context workspace-context'
- 'editors preview';
-}
-
.panels-stack {
min-width: 0;
}