Skip to content

Commit cfb915b

Browse files
feat: tab renaming and fallback behavior.
1 parent 108a3c1 commit cfb915b

4 files changed

Lines changed: 225 additions & 7 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
## Plan: Issue 62 Remaining Hardening
2+
3+
Deliver focused hardening in @knighted/develop by executing the remaining focus areas in the listed priority order, while adding deterministic tests around each behavior contract. Keep changes localized to tab-state orchestration, iframe preview/runtime plumbing, and workspace module resolution; avoid broad UX redesign.
4+
5+
**Steps**
6+
7+
1. Phase 1: Remove/Add/Rename Coherence (Focus area 1)
8+
2. Add deterministic Playwright coverage for remove fallback ordering: removing active non-entry tab selects the next deterministic tab by workspace order, and removing non-active tab does not change active tab.
9+
3. Tighten remove flow in app orchestration to keep active tab id, loaded editor lane ids, and visible editor panel synchronized after deletion; verify no stale lane id references survive tab removal.
10+
4. Add invariants for add/rename synchronization so name/path/content stay aligned in single upsert operations, including rapid rename after add and repeated rename collisions.
11+
5. Extend rename behavior to preserve entry filename contract while ensuring metadata persistence (role, language, path) remains coherent under fast user interactions.
12+
6. Phase 2: Tab-ID-First Activation Hardening (Focus area 2) (depends on Phase 1)
13+
7. Enforce active tab id as the only activation source across tab click, keyboard focus, and programmatic activation flows; reject stale/hidden-panel driven activation attempts.
14+
8. Add guard tests for hidden panel interactions: toggling/collapsing panels during tab switches cannot mutate active tab state or write editor content to the wrong tab.
15+
9. Validate content persistence order around activation: active editor content is flushed before tab transition, and editor pool activation always occurs after visibility state is set.
16+
10. Phase 3: Entry and Startup Determinism (Focus area 3) (depends on Phases 1-2)
17+
11. Stabilize startup restore sequence by validating stored active tab id against restored tab graph, then loading editor lane strictly from resolved active id.
18+
12. Add resilience tests for corrupted or stale persisted records (missing active tab id, missing entry tab, stale role metadata), ensuring deterministic fallback behavior.
19+
13. Lock preview entry resolution to entry-role metadata contract with documented fallback when role metadata is invalid or absent.
20+
14. Add startup sequencing checks so first render cannot run with stale default editor content before workspace hydration completes.
21+
15. Phase 4: Workspace Import Specifier Compatibility (Focus area 4) (parallel with Phase 5 once startup contract is stable)
22+
16. Implement deterministic resolver ordering contract: exact module-key match first, extension-compat fallback second (.ts/.tsx/.js/.jsx/.mjs), with explicit ambiguity detection and surfaced diagnostics.
23+
17. Add tests for ESM-style .js specifiers resolving to .ts/.tsx workspace tabs when exact .js target does not exist; add ambiguity tests where multiple candidates exist.
24+
18. Preserve deterministic circular/missing module diagnostics while integrating extension-compat behavior.
25+
19. Phase 5: Extension-Driven Tab Kind Detection (Focus area 5) (parallel with Phase 4)
26+
20. Update add-tab flow to require explicit type selection (Component or Styles) per user decision; remove Auto default behavior from creation flow.
27+
21. Add extension/path inference utility used during add and rename for language/tooling alignment, while explicit add selection overrides extension inference for initial creation.
28+
22. Ensure rename can reclassify tab kind by extension (.css/.less/.sass/.scss/.module.css semantics) and keep editor language, panel tools, and render pipeline wiring in sync.
29+
23. Add Playwright coverage for creating and renaming style-extension tabs and verifying styles-lane behavior.
30+
24. Phase 6: React Runtime Correctness in Iframe Preview (Focus area 6) (depends on Phases 2-3)
31+
25. Audit and harden runtime prelude + transpile option parity between DOM and React modes so generated modules always bind expected runtime helpers for current mode.
32+
26. Investigate and fix children-related runtime regression path in iframe execution, including stale module execution, transform output mismatch, and mode-switch residue.
33+
27. Strengthen iframe runtime bridge state reset on mode switches/rerenders so stale caches/fingerprints do not mask or replay outdated runtime failures.
34+
28. Add targeted e2e regression test for children-prop component usage across DOM/React mode transitions and repeated rerenders.
35+
29. Phase 7: Render Cadence and Stale-Error Recovery (Focus area 7) (depends on Phase 6)
36+
30. Verify render scheduling, disposal, and rerender queue behavior under rapid tab churn and mode switches; ensure stale module graphs are fully disposed.
37+
31. Tune runtime error dedupe lifecycle to avoid noisy duplicates while still allowing deterministic recovery signaling after user fixes.
38+
32. Add stress-style tests for repeated source edits, rapid mode flips, and recovery from runtime/transform errors without unrelated edits.
39+
33. Validation and release-readiness (depends on all phases)
40+
34. Run lint/build and only the relevant e2e specs for touched behavior during iteration; reserve full e2e suite for end-of-work handoff.
41+
35. Update docs only if any user-visible behavior contract changed (especially add-tab type selection and resolver ambiguity reporting).
42+
43+
**Relevant files**
44+
45+
- /Users/morgan/knighted/develop/src/app.js — primary tab activation, add/remove/rename, startup restore, editor lane visibility, persistence orchestration.
46+
- /Users/morgan/knighted/develop/src/modules/workspace-tabs-state.js — active tab id state machine and deterministic fallback logic.
47+
- /Users/morgan/knighted/develop/src/modules/workspace-storage.js — persisted record normalization and tab metadata consistency.
48+
- /Users/morgan/knighted/develop/src/modules/preview-entry-resolver.js — entry tab resolution contract and fallback behavior.
49+
- /Users/morgan/knighted/develop/src/modules/preview-runtime/virtual-workspace-modules.js — module planning, extension-compatible specifier resolution, runtime prelude injection, blob lifecycle.
50+
- /Users/morgan/knighted/develop/src/modules/preview-runtime/workspace-hydration.js — module key derivation from tab metadata.
51+
- /Users/morgan/knighted/develop/src/modules/preview-runtime/iframe-preview-executor.js — iframe error bridge, runtime error dedupe/fingerprinting, execution bootstrap.
52+
- /Users/morgan/knighted/develop/src/modules/render-runtime.js — render scheduling, mode dispatch, module disposal, runtime error normalization/surfacing.
53+
- /Users/morgan/knighted/develop/playwright/rendering-modes.spec.ts — runtime mode switching and runtime error surfacing regression tests.
54+
- /Users/morgan/knighted/develop/playwright/diagnostics.spec.ts — diagnostics reporting expectations and classification.
55+
- /Users/morgan/knighted/develop/playwright/helpers/app-test-helpers.ts — tab operation and editor interaction helpers used by new coverage.
56+
57+
**Verification**
58+
59+
1. Add/adjust Playwright scenarios for each phase before implementation finalization of that phase to lock behavior.
60+
2. During iteration, run only relevant Playwright specs for the behavior being changed (for example: runtime-only, tabs-only, diagnostics-only) to keep feedback fast.
61+
3. Run npm run lint in /Users/morgan/knighted/develop after JS edits.
62+
4. Run npm run build in /Users/morgan/knighted/develop after runtime/resolution/startup path edits.
63+
5. Keep full npm run test:e2e as final end-of-work validation gate.
64+
6. Manual verification matrix: startup restore, entry tab stability, tab add/rename/remove determinism, .js to .ts/.tsx specifier resolution, style-extension tab behavior, children-prop runtime behavior across DOM/React switching.
65+
66+
**Decisions**
67+
68+
- Keep implementation priority in the exact order already listed in issue 62 prompt focus areas.
69+
- Add-tab creation will require explicit type choice (Component or Styles); no Auto default in the add flow.
70+
- Scope is limited to @knighted/develop runtime/state/test/docs touchpoints; no dependency additions and no broad UI redesign.
71+
- Implementation structure rule: reuse existing modules first; when new modules are required, colocate them under existing parent module directories and keep responsibilities split (no monolithic new implementation file).
72+
- Preserve existing preference: keep error surfacing in PR drawer status context and avoid introducing app-level error toast behavior changes.
73+
74+
**Further Considerations**
75+
76+
1. Resolver ambiguity UX: recommend deterministic hard error with candidate list in diagnostics over silent first-match behavior.
77+
2. Runtime dedupe lifecycle: recommend resetting dedupe state per render cycle boundary (not global session) to improve stale-error recovery determinism.
78+
3. For rename-driven reclassification, recommend preserving tab id while migrating language/kind metadata to avoid persistence churn.

playwright/workspace-tabs.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { expect, test } from '@playwright/test'
2+
import {
3+
addWorkspaceTab,
4+
setWorkspaceTabSource,
5+
waitForInitialRender,
6+
} from './helpers/app-test-helpers.js'
7+
8+
const confirmRemoveDialog = async (page: import('@playwright/test').Page) => {
9+
const dialog = page.locator('#clear-confirm-dialog')
10+
await expect(dialog).toBeVisible()
11+
12+
await dialog.locator('button[value="confirm"]').evaluate(element => {
13+
if (element instanceof HTMLButtonElement) {
14+
element.click()
15+
}
16+
})
17+
}
18+
19+
const renameWorkspaceTab = async (
20+
page: import('@playwright/test').Page,
21+
{
22+
from,
23+
to,
24+
}: {
25+
from: string
26+
to: string
27+
},
28+
) => {
29+
await page.getByRole('button', { name: `Rename tab ${from}` }).click()
30+
const renameInput = page.getByLabel(`Rename ${from}`)
31+
await renameInput.fill(to)
32+
await renameInput.press('Enter')
33+
}
34+
35+
test('removing active tab selects deterministic adjacent tab', async ({ page }) => {
36+
await waitForInitialRender(page)
37+
38+
await addWorkspaceTab(page)
39+
await addWorkspaceTab(page)
40+
await addWorkspaceTab(page)
41+
42+
await page.getByRole('tab', { name: 'Open tab module-2.tsx' }).click()
43+
await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveAttribute(
44+
'aria-selected',
45+
'true',
46+
)
47+
48+
await page.getByRole('button', { name: 'Remove tab module-2.tsx' }).click()
49+
await confirmRemoveDialog(page)
50+
51+
await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveCount(0)
52+
await expect(page.getByRole('tab', { name: 'Open tab module-3.tsx' })).toHaveAttribute(
53+
'aria-selected',
54+
'true',
55+
)
56+
})
57+
58+
test('removing non-active tab does not change active tab', async ({ page }) => {
59+
await waitForInitialRender(page)
60+
61+
await addWorkspaceTab(page)
62+
await addWorkspaceTab(page)
63+
await addWorkspaceTab(page)
64+
65+
await page.getByRole('tab', { name: 'Open tab module-3.tsx' }).click()
66+
await expect(page.getByRole('tab', { name: 'Open tab module-3.tsx' })).toHaveAttribute(
67+
'aria-selected',
68+
'true',
69+
)
70+
71+
await page.getByRole('button', { name: 'Remove tab module-2.tsx' }).click()
72+
await confirmRemoveDialog(page)
73+
74+
await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveCount(0)
75+
await expect(page.getByRole('tab', { name: 'Open tab module-3.tsx' })).toHaveAttribute(
76+
'aria-selected',
77+
'true',
78+
)
79+
})
80+
81+
test('renaming module tab keeps name and path synchronized', async ({ page }) => {
82+
await waitForInitialRender(page)
83+
84+
await addWorkspaceTab(page)
85+
await renameWorkspaceTab(page, {
86+
from: 'module.tsx',
87+
to: 'card-item.tsx',
88+
})
89+
90+
const tab = page.getByRole('tab', { name: 'Open tab card-item.tsx' })
91+
await expect(tab).toHaveAttribute('title', 'src/components/card-item.tsx')
92+
await expect(page.getByRole('tab', { name: 'Open tab module.tsx' })).toHaveCount(0)
93+
})
94+
95+
test('renaming module tab preserves source content', async ({ page }) => {
96+
await waitForInitialRender(page)
97+
98+
await addWorkspaceTab(page)
99+
await setWorkspaceTabSource(page, {
100+
fileName: 'module.tsx',
101+
source: 'export const Value = () => <p>Kept</p>',
102+
kind: 'component',
103+
})
104+
105+
await renameWorkspaceTab(page, {
106+
from: 'module.tsx',
107+
to: 'value-card.tsx',
108+
})
109+
110+
await page.getByRole('tab', { name: 'Open tab App.tsx' }).click()
111+
await page.getByRole('tab', { name: 'Open tab value-card.tsx' }).click()
112+
113+
const editorContent = page
114+
.locator('.editor-panel[data-editor-kind="component"] .cm-content')
115+
.first()
116+
await expect(editorContent).toContainText('export const Value = () => <p>Kept</p>')
117+
})

src/app.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,23 @@ const normalizeEntryTabPath = (path, { preferredFileName = '' } = {}) => {
962962
return `${directory}/${fileName}`
963963
}
964964

965+
const normalizeModuleTabPathForRename = (path, nextName) => {
966+
const currentPath = toNonEmptyWorkspaceText(path)
967+
const normalizedNextName = toNonEmptyWorkspaceText(nextName)
968+
const nextFileName = getPathFileName(normalizedNextName) || normalizedNextName
969+
970+
if (!nextFileName) {
971+
return currentPath
972+
}
973+
974+
if (!currentPath) {
975+
return nextFileName
976+
}
977+
978+
const directory = getPathDirectory(currentPath)
979+
return `${directory}/${nextFileName}`
980+
}
981+
965982
const setVisibleEditorPanelForKind = kind => {
966983
const nextVisibleKind = kind === 'styles' ? 'styles' : 'component'
967984

@@ -1386,7 +1403,8 @@ const finishWorkspaceTabRename = ({ tabId, nextName, cancelled = false }) => {
13861403
return
13871404
}
13881405

1389-
const normalizedName = toNonEmptyWorkspaceText(nextName)
1406+
const normalizedNameInput = toNonEmptyWorkspaceText(nextName)
1407+
const normalizedName = getPathFileName(normalizedNameInput) || normalizedNameInput
13901408
if (!normalizedName) {
13911409
setStatus('Tab name cannot be empty.', 'error')
13921410
renderWorkspaceTabs()
@@ -1405,11 +1423,11 @@ const finishWorkspaceTabRename = ({ tabId, nextName, cancelled = false }) => {
14051423
const normalizedEntryPath =
14061424
tab.role === 'entry'
14071425
? normalizeEntryTabPath(tab.path, { preferredFileName: normalizedName })
1408-
: toNonEmptyWorkspaceText(tab.path)
1426+
: normalizeModuleTabPathForRename(tab.path, normalizedName)
14091427
const normalizedTabName =
14101428
tab.role === 'entry'
14111429
? getPathFileName(normalizedEntryPath) || defaultComponentTabName
1412-
: normalizedName
1430+
: getPathFileName(normalizedEntryPath) || normalizedName
14131431

14141432
workspaceTabsState.upsertTab({
14151433
...tab,

src/modules/workspace-tabs-state.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,19 @@ export const createWorkspaceTabsState = ({ tabs = [], activeTabId, onChange } =
188188
return false
189189
}
190190

191+
const removedIndex = orderedIds.indexOf(id)
191192
tabsById.delete(id)
192-
const index = orderedIds.indexOf(id)
193-
if (index >= 0) {
194-
orderedIds.splice(index, 1)
193+
if (removedIndex >= 0) {
194+
orderedIds.splice(removedIndex, 1)
195195
}
196196

197197
if (activeTabId === id) {
198-
activeTabId = orderedIds[0] || ''
198+
if (orderedIds.length === 0) {
199+
activeTabId = ''
200+
} else {
201+
const fallbackIndex = Math.min(Math.max(removedIndex, 0), orderedIds.length - 1)
202+
activeTabId = orderedIds[fallbackIndex] || orderedIds[0] || ''
203+
}
199204
}
200205

201206
const resolvedActiveTabId = getActiveTabId()

0 commit comments

Comments
 (0)