From d8989f006dd205b272f860aa7cfdda3d9470bd25 Mon Sep 17 00:00:00 2001 From: KCM Date: Thu, 23 Apr 2026 14:23:38 -0500 Subject: [PATCH 1/2] fix(tabs): filename edits, min-widths, scrolling, workspace content sync. --- playwright/workspace-tabs.spec.ts | 93 +++++++++++++++++++ .../app-core/workspace-editor-helpers.js | 18 ++-- .../workspace-tab-mutations-controller.js | 18 ++-- .../app-core/workspace-tabs-renderer.js | 2 +- .../workspace/workspace-tab-helpers.js | 5 + src/styles/panels-editor.css | 6 +- 6 files changed, 128 insertions(+), 14 deletions(-) diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index f63640e..c1c22d7 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -220,6 +220,24 @@ test('renaming module tab keeps name and path synchronized', async ({ page }) => await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveCount(0) }) +test('renaming module tab input starts with full path and supports directory changes', async ({ + page, +}) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await page.getByRole('button', { name: 'Rename tab module.tsx' }).click() + + const renameInput = page.getByLabel('Rename module.tsx') + await expect(renameInput).toHaveValue('src/components/module.tsx') + await renameInput.fill('src/ui/cards/card-item.tsx') + await renameInput.press('Enter') + + const tab = page.getByRole('button', { name: 'Open tab card-item.tsx' }) + await expect(tab).toHaveAttribute('title', 'src/ui/cards/card-item.tsx') + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveCount(0) +}) + test('renaming module tab preserves source content', async ({ page }) => { await waitForInitialRender(page) @@ -244,6 +262,81 @@ test('renaming module tab preserves source content', async ({ page }) => { await expect(editorContent).toContainText('export const Value = () =>

Kept

') }) +test('rapid tab churn keeps module content isolated from entry content', async ({ + page, +}) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'boop.tsx', + }) + await renameWorkspaceTab(page, { + from: 'module-2.tsx', + to: 'beep.tsx', + }) + + const appSource = [ + "import './styles/styles.css'", + "import { Boop } from './components/boop.js'", + "import { Beep } from './components/beep.js'", + '', + 'export function App() {', + ' return (', + ' <>', + ' ', + ' ', + ' ', + ' )', + '}', + ].join('\n') + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + kind: 'component', + source: appSource, + }) + await setWorkspaceTabSource(page, { + fileName: 'boop.tsx', + kind: 'component', + source: 'export const Boop = () =>

boop sentinel

', + }) + await setWorkspaceTabSource(page, { + fileName: 'beep.tsx', + kind: 'component', + source: 'export const Beep = () =>

beep sentinel

', + }) + + await page.getByRole('button', { name: 'Open tab boop.tsx' }).click() + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + + await page.getByRole('button', { name: 'Remove tab module-3.tsx' }).click() + await confirmRemoveDialog(page) + await addWorkspaceTab(page) + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + const entryEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await expect(entryEditor).toContainText("import './styles/styles.css'") + await expect(entryEditor).toContainText("import { Beep } from './components/beep.js'") + + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + const beepEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await expect(beepEditor).toContainText('export const Beep = () =>

beep sentinel

') + await expect(beepEditor).not.toContainText("import './styles/styles.css'") +}) + test('active tab remains source of truth for visible editor panel', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/modules/app-core/workspace-editor-helpers.js b/src/modules/app-core/workspace-editor-helpers.js index 162af5c..b59ee29 100644 --- a/src/modules/app-core/workspace-editor-helpers.js +++ b/src/modules/app-core/workspace-editor-helpers.js @@ -77,20 +77,26 @@ const createWorkspaceEditorHelpers = ({ return } - const nextContent = - getTabKind(activeTab) === 'styles' ? getCssSource() : getJsxSource() + const activeTabKind = getTabKind(activeTab) + const loadedTabId = + activeTabKind === 'styles' ? getLoadedStylesTabId() : getLoadedComponentTabId() + const loadedTab = loadedTabId ? workspaceTabsState.getTab(loadedTabId) : null + const targetTab = + loadedTab && getTabKind(loadedTab) === activeTabKind ? loadedTab : activeTab - if (nextContent === activeTab.content) { + const nextContent = activeTabKind === 'styles' ? getCssSource() : getJsxSource() + + if (nextContent === targetTab.content) { return } workspaceTabsState.upsertTab( { - ...activeTab, + ...targetTab, content: nextContent, - isDirty: getDirtyStateForTabChange(activeTab, nextContent), + isDirty: getDirtyStateForTabChange(targetTab, nextContent), lastModified: Date.now(), - isActive: true, + isActive: targetTab.id === activeTab.id, }, { emitReason: 'tabContentSync' }, ) diff --git a/src/modules/app-core/workspace-tab-mutations-controller.js b/src/modules/app-core/workspace-tab-mutations-controller.js index 206b703..5840b07 100644 --- a/src/modules/app-core/workspace-tab-mutations-controller.js +++ b/src/modules/app-core/workspace-tab-mutations-controller.js @@ -50,16 +50,18 @@ const createWorkspaceTabMutationsController = ({ } const normalizedNameInput = toNonEmptyWorkspaceText(nextName) - const normalizedName = getPathFileName(normalizedNameInput) || normalizedNameInput - if (!normalizedName) { + if (!normalizedNameInput) { setStatus('Tab name cannot be empty.', 'error') renderWorkspaceTabs() return } + const includesDirectory = /[\\/]/.test(normalizedNameInput) + const nextFileName = getPathFileName(normalizedNameInput) || normalizedNameInput + if ( tab.role === 'entry' && - !allowedEntryTabFileNames.has(normalizedName.toLowerCase()) + !allowedEntryTabFileNames.has(nextFileName.toLowerCase()) ) { setStatus('Entry tab name must be App.tsx or App.js.', 'error') renderWorkspaceTabs() @@ -68,12 +70,16 @@ const createWorkspaceTabMutationsController = ({ const normalizedEntryPath = tab.role === 'entry' - ? normalizeEntryTabPath(tab.path, { preferredFileName: normalizedName }) - : normalizeModuleTabPathForRename(tab.path, normalizedName) + ? normalizeEntryTabPath(includesDirectory ? normalizedNameInput : tab.path, { + preferredFileName: includesDirectory + ? getPathFileName(normalizedNameInput) + : normalizedNameInput, + }) + : normalizeModuleTabPathForRename(tab.path, normalizedNameInput) const normalizedTabName = tab.role === 'entry' ? getPathFileName(normalizedEntryPath) || defaultComponentTabName - : getPathFileName(normalizedEntryPath) || normalizedName + : getPathFileName(normalizedEntryPath) || nextFileName const didPathChange = typeof tab?.path === 'string' && normalizedEntryPath !== tab.path diff --git a/src/modules/app-core/workspace-tabs-renderer.js b/src/modules/app-core/workspace-tabs-renderer.js index 09c6937..3e8d59c 100644 --- a/src/modules/app-core/workspace-tabs-renderer.js +++ b/src/modules/app-core/workspace-tabs-renderer.js @@ -159,7 +159,7 @@ const createWorkspaceTabsRenderer = ({ if (isRenaming) { const renameInput = document.createElement('input') renameInput.className = 'workspace-tab__name-input' - renameInput.value = tab.name + renameInput.value = tab.path || tab.name renameInput.setAttribute('aria-label', `Rename ${tab.name}`) let renameResolved = false diff --git a/src/modules/workspace/workspace-tab-helpers.js b/src/modules/workspace/workspace-tab-helpers.js index 44205b7..ea14246 100644 --- a/src/modules/workspace/workspace-tab-helpers.js +++ b/src/modules/workspace/workspace-tab-helpers.js @@ -228,6 +228,11 @@ const normalizeModuleTabPathForRename = ( ) => { const currentPath = toNonEmptyWorkspaceText(path) const normalizedNextName = toNonEmptyWorkspaceText(nextName) + + if (/[\\/]/.test(normalizedNextName)) { + return normalizeWorkspacePathValue(normalizedNextName) + } + const nextFileName = getPathFileName(normalizedNextName) || normalizedNextName if (!nextFileName) { diff --git a/src/styles/panels-editor.css b/src/styles/panels-editor.css index 0eabadf..6b9e22d 100644 --- a/src/styles/panels-editor.css +++ b/src/styles/panels-editor.css @@ -269,6 +269,10 @@ border-top-color: var(--border-control); } +.workspace-tabs-strip:has(> .workspace-tab:nth-child(n + 7)) > .workspace-tab { + min-width: 148px; +} + .workspace-tab:active { cursor: grabbing; } @@ -324,7 +328,7 @@ } .workspace-tab__name-input { - width: min(220px, 40vw); + width: min(320px, 52vw); border: 1px solid var(--border-control); border-radius: 8px; background: var(--surface-select); From 355767dd7656461b31772291c0f8b83f987294fd Mon Sep 17 00:00:00 2001 From: KCM Date: Thu, 23 Apr 2026 14:38:36 -0500 Subject: [PATCH 2/2] refactor: address pr comments. --- playwright/workspace-tabs.spec.ts | 54 +++++++++++++++++++ .../workspace-tab-mutations-controller.js | 23 ++++++++ .../workspace/workspace-tab-helpers.js | 12 ++++- 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index c1c22d7..408c4ae 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -238,6 +238,60 @@ test('renaming module tab input starts with full path and supports directory cha await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveCount(0) }) +test('renaming module tab ignores invalid path-style input', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + + const invalidPathInputs = [ + 'src/ui/cards/', + '/src/ui/cards/card-item.tsx', + '../card-item.tsx', + 'src/ui/../card-item.tsx', + 'src/ui/card item.tsx', + ] + + for (const input of invalidPathInputs) { + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: input, + }) + + const moduleTab = page.getByRole('button', { name: 'Open tab module.tsx' }) + await expect(moduleTab).toHaveAttribute('title', 'src/components/module.tsx') + await expect( + page.getByRole('button', { name: 'Open tab card-item.tsx' }), + ).toHaveCount(0) + } +}) + +test('renaming module tab rejects path collisions with existing tabs', async ({ + page, +}) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await renameWorkspaceTab(page, { + from: 'module-2.tsx', + to: 'module.tsx', + }) + + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Open tab module-2.tsx' })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveAttribute( + 'title', + 'src/components/module.tsx', + ) + await expect( + page.getByRole('button', { name: 'Open tab module-2.tsx' }), + ).toHaveAttribute('title', 'src/components/module-2.tsx') + await expect(page.getByRole('status', { name: 'App status' })).toContainText( + 'A tab with that file path already exists.', + ) +}) + test('renaming module tab preserves source content', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/modules/app-core/workspace-tab-mutations-controller.js b/src/modules/app-core/workspace-tab-mutations-controller.js index 5840b07..227418d 100644 --- a/src/modules/app-core/workspace-tab-mutations-controller.js +++ b/src/modules/app-core/workspace-tab-mutations-controller.js @@ -76,6 +76,29 @@ const createWorkspaceTabMutationsController = ({ : normalizedNameInput, }) : normalizeModuleTabPathForRename(tab.path, normalizedNameInput) + + const normalizePathForComparison = value => + toNonEmptyWorkspaceText(value).replace(/\\/g, '/').replace(/\/+/g, '/') + const normalizedNextPath = normalizePathForComparison(normalizedEntryPath) + const hasPathCollision = workspaceTabsState.getTabs().some(existingTab => { + if (!existingTab || existingTab.id === tab.id) { + return false + } + + const existingPath = normalizePathForComparison(existingTab.path) + const existingTargetPath = normalizePathForComparison(existingTab.targetPrFilePath) + return ( + (existingPath && existingPath === normalizedNextPath) || + (existingTargetPath && existingTargetPath === normalizedNextPath) + ) + }) + + if (hasPathCollision) { + setStatus('A tab with that file path already exists.', 'error') + renderWorkspaceTabs() + return + } + const normalizedTabName = tab.role === 'entry' ? getPathFileName(normalizedEntryPath) || defaultComponentTabName diff --git a/src/modules/workspace/workspace-tab-helpers.js b/src/modules/workspace/workspace-tab-helpers.js index ea14246..234788c 100644 --- a/src/modules/workspace/workspace-tab-helpers.js +++ b/src/modules/workspace/workspace-tab-helpers.js @@ -230,7 +230,17 @@ const normalizeModuleTabPathForRename = ( const normalizedNextName = toNonEmptyWorkspaceText(nextName) if (/[\\/]/.test(normalizedNextName)) { - return normalizeWorkspacePathValue(normalizedNextName) + const normalizedPathInput = normalizeWorkspacePathValue(normalizedNextName) + const segments = normalizedPathInput.split('/').filter(Boolean) + const isValidPathInput = + Boolean(normalizedPathInput) && + !normalizedPathInput.startsWith('/') && + !normalizedPathInput.endsWith('/') && + segments.length > 0 && + segments.every(segment => segment !== '.' && segment !== '..') && + /^[A-Za-z0-9._\-/]+$/.test(normalizedPathInput) + + return isValidPathInput ? normalizedPathInput : currentPath } const nextFileName = getPathFileName(normalizedNextName) || normalizedNextName