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
147 changes: 147 additions & 0 deletions playwright/workspace-tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,78 @@ 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 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)

Expand All @@ -244,6 +316,81 @@ test('renaming module tab preserves source content', async ({ page }) => {
await expect(editorContent).toContainText('export const Value = () => <p>Kept</p>')
})

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 (',
' <>',
' <Boop />',
' <Beep />',
' </>',
' )',
'}',
].join('\n')

await setWorkspaceTabSource(page, {
fileName: 'App.tsx',
kind: 'component',
source: appSource,
})
await setWorkspaceTabSource(page, {
fileName: 'boop.tsx',
kind: 'component',
source: 'export const Boop = () => <p>boop sentinel</p>',
})
await setWorkspaceTabSource(page, {
fileName: 'beep.tsx',
kind: 'component',
source: 'export const Beep = () => <p>beep sentinel</p>',
})

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 = () => <p>beep sentinel</p>')
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)

Expand Down
18 changes: 12 additions & 6 deletions src/modules/app-core/workspace-editor-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
)
Expand Down
41 changes: 35 additions & 6 deletions src/modules/app-core/workspace-tab-mutations-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
knightedcodemonkey marked this conversation as resolved.
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()
Expand All @@ -68,12 +70,39 @@ 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 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
: getPathFileName(normalizedEntryPath) || normalizedName
: getPathFileName(normalizedEntryPath) || nextFileName
Comment thread
knightedcodemonkey marked this conversation as resolved.
const didPathChange =
typeof tab?.path === 'string' && normalizedEntryPath !== tab.path

Expand Down
2 changes: 1 addition & 1 deletion src/modules/app-core/workspace-tabs-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/modules/workspace/workspace-tab-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,21 @@ const normalizeModuleTabPathForRename = (
) => {
const currentPath = toNonEmptyWorkspaceText(path)
const normalizedNextName = toNonEmptyWorkspaceText(nextName)

if (/[\\/]/.test(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
}
Comment thread
knightedcodemonkey marked this conversation as resolved.

const nextFileName = getPathFileName(normalizedNextName) || normalizedNextName

if (!nextFileName) {
Expand Down
6 changes: 5 additions & 1 deletion src/styles/panels-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
Loading