From 8563cb383b622d476dfa3ee0c110968765044f4f Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 18 Apr 2026 10:53:53 -0500 Subject: [PATCH 1/5] refactor: dirty styles. --- playwright/workspace-tabs.spec.ts | 3 ++- src/app.js | 15 +++++++++-- src/index.html | 20 ++++++++++++-- .../app-core/workspace-editor-helpers.js | 12 +++++++++ .../app-core/workspace-tabs-renderer.js | 4 +-- src/styles/panels-editor.css | 27 ++++++++++++++----- 6 files changed, 68 insertions(+), 13 deletions(-) diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index 7d7035b..225da17 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -282,7 +282,8 @@ test('editing a synced tab marks it dirty', async ({ page }) => { const componentTab = page .getByRole('listitem', { name: 'Workspace tab App.tsx' }) .first() - await expect(componentTab).toContainText('Dirty') + await expect(componentTab.locator('.workspace-tab__dirty-indicator')).toBeVisible() + await expect(page.locator('#component-dirty-status')).toHaveText('Edited') }) test('removed default styles tab stays removed after reload', async ({ page }) => { diff --git a/src/app.js b/src/app.js index 22ce793..c11dd54 100644 --- a/src/app.js +++ b/src/app.js @@ -135,8 +135,14 @@ const componentPrSyncIcon = document.getElementById('component-pr-sync-icon') const componentPrSyncIconPath = document.getElementById('component-pr-sync-icon-path') const stylesPrSyncIcon = document.getElementById('styles-pr-sync-icon') const stylesPrSyncIconPath = document.getElementById('styles-pr-sync-icon-path') -const componentEditorHeaderLabel = document.querySelector('#editor-header-component span') -const stylesEditorHeaderLabel = document.querySelector('#editor-header-styles span') +const componentEditorHeaderLabel = document.querySelector( + '#editor-header-component [data-editor-header-label]', +) +const stylesEditorHeaderLabel = document.querySelector( + '#editor-header-styles [data-editor-header-label]', +) +const componentEditorDirtyStatus = document.getElementById('component-dirty-status') +const stylesEditorDirtyStatus = document.getElementById('styles-dirty-status') const aiControlsToggle = document.getElementById('ai-controls-toggle') const appThemeButtons = document.querySelectorAll('[data-app-theme]') const workspaceTabsShell = document.getElementById('workspace-tabs-shell') @@ -194,6 +200,10 @@ const editorHeaderLabelByKind = { component: componentEditorHeaderLabel, styles: stylesEditorHeaderLabel, } +const editorHeaderDirtyStatusByKind = { + component: componentEditorDirtyStatus, + styles: stylesEditorDirtyStatus, +} const defaultTabNameByKind = { component: defaultComponentTabName, styles: defaultStylesTabName, @@ -499,6 +509,7 @@ const { editorKinds, editorPanelsByKind, editorHeaderLabelByKind, + editorHeaderDirtyStatusByKind, defaultTabNameByKind, toNonEmptyWorkspaceText, getLoadedStylesTabId: () => loadedStylesTabId, diff --git a/src/index.html b/src/index.html index f9d8962..18fa2fb 100644 --- a/src/index.html +++ b/src/index.html @@ -376,7 +376,15 @@

>

- Component + Component + >

- Styles + Styles + Date: Sat, 18 Apr 2026 11:16:51 -0500 Subject: [PATCH 2/5] fix: only show edited when in pr context. --- AGENTS.md | 1 + playwright/workspace-tabs.spec.ts | 8 ++-- src/app.js | 19 ++++++++ .../edited-indicator-visibility-controller.js | 43 +++++++++++++++++++ .../app-core/github-workflows-setup.js | 1 + src/modules/app-core/github-workflows.js | 8 ++++ .../app-core/workspace-controllers-setup.js | 2 + .../app-core/workspace-editor-helpers.js | 7 ++- .../app-core/workspace-tabs-renderer.js | 7 ++- 9 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 src/modules/app-core/edited-indicator-visibility-controller.js diff --git a/AGENTS.md b/AGENTS.md index 51531b0..f2ee6ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ Repository structure: - Preserve current project formatting: single quotes, no semicolons, print width 90, arrowParens avoid. - Do not use index files or barrel-file architecture; prefer explicit file names and explicit import paths. +- Prefer modular, colocated architecture; split focused features into nearby files and avoid monolithic modules. - Keep UI changes intentional and lightweight; avoid broad visual rewrites unless requested. - Keep runtime logic defensive for flaky/slow CDN conditions. - Preserve progressive loading behavior (lazy-load optional compilers/runtime pieces where possible). diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index 225da17..becfd4f 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -266,7 +266,9 @@ test('startup restores last active workspace tab after reload', async ({ page }) await expect(page.locator('#editor-panel-styles')).toHaveAttribute('hidden', '') }) -test('editing a synced tab marks it dirty', async ({ page }) => { +test('editing a synced tab keeps dirty state local without Edited indicators', async ({ + page, +}) => { await waitForInitialRender(page) await seedSyncedComponentTab(page) @@ -282,8 +284,8 @@ test('editing a synced tab marks it dirty', async ({ page }) => { const componentTab = page .getByRole('listitem', { name: 'Workspace tab App.tsx' }) .first() - await expect(componentTab.locator('.workspace-tab__dirty-indicator')).toBeVisible() - await expect(page.locator('#component-dirty-status')).toHaveText('Edited') + await expect(componentTab.locator('.workspace-tab__dirty-indicator')).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() }) test('removed default styles tab stays removed after reload', async ({ page }) => { diff --git a/src/app.js b/src/app.js index c11dd54..5f6a3db 100644 --- a/src/app.js +++ b/src/app.js @@ -29,6 +29,7 @@ import { toStyleModeForTabLanguage, } from './modules/app-core/workspace-local-helpers.js' import { createWorkspaceEditorHelpers } from './modules/app-core/workspace-editor-helpers.js' +import { createEditedIndicatorVisibilityController } from './modules/app-core/edited-indicator-visibility-controller.js' import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js' import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js' import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js' @@ -436,6 +437,11 @@ const prContextUi = createGitHubPrContextUiController({ closeWorkspacesDrawer: () => workspacesDrawerController?.setOpen(false), }) +const editedIndicatorVisibilityController = createEditedIndicatorVisibilityController({ + getToken: () => githubAiContextState.token, + getActivePrContext: () => githubAiContextState.activePrContext, +}) + const byotControls = createGitHubByotControls({ controlsRoot: githubAiControls, tokenInput: githubTokenInput, @@ -472,6 +478,7 @@ const byotControls = createGitHubByotControls({ prContextUi.syncAiChatTokenVisibility(token) chatDrawerController.setToken(token) prDrawerController.setToken(token) + editedIndicatorVisibilityController.refreshIndicators() }, setStatus, }) @@ -510,6 +517,8 @@ const { editorPanelsByKind, editorHeaderLabelByKind, editorHeaderDirtyStatusByKind, + getShouldShowEditedDesign: + editedIndicatorVisibilityController.getShouldShowEditedDesign, defaultTabNameByKind, toNonEmptyWorkspaceText, getLoadedStylesTabId: () => loadedStylesTabId, @@ -660,6 +669,8 @@ const { setHasPendingWorkspaceTabsRender: value => (hasPendingWorkspaceTabsRender = value), persistActiveTabEditorContent, getWorkspaceTabDisplay, + getShouldShowEditedDesign: + editedIndicatorVisibilityController.getShouldShowEditedDesign, workspaceTabsShell, workspaceTabAddWrap, setWorkspaceTabRenameState: value => (workspaceTabRenameState = value), @@ -684,6 +695,11 @@ const { createWorkspaceTabId, }) +editedIndicatorVisibilityController.setRefreshHandlers({ + syncHeaderLabels, + renderWorkspaceTabs, +}) + const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, @@ -758,6 +774,9 @@ const githubWorkflows = createGitHubWorkflowsSetup({ getStyleMode: () => styleMode.value, getActivePrContextSyncKey, prContextUi, + onPrContextStateChange: () => { + editedIndicatorVisibilityController.refreshIndicators() + }, getTokenForVisibility: () => githubAiContextState.token, closeWorkspacesDrawer: () => { void workspacesDrawerController?.setOpen(false) diff --git a/src/modules/app-core/edited-indicator-visibility-controller.js b/src/modules/app-core/edited-indicator-visibility-controller.js new file mode 100644 index 0000000..cb80ad1 --- /dev/null +++ b/src/modules/app-core/edited-indicator-visibility-controller.js @@ -0,0 +1,43 @@ +const createEditedIndicatorVisibilityController = ({ + getToken, + getActivePrContext, +} = {}) => { + let runRefresh = () => {} + + const hasToken = () => { + const token = typeof getToken === 'function' ? getToken() : '' + return typeof token === 'string' && token.trim().length > 0 + } + + const hasActivePrContext = () => { + const activePrContext = + typeof getActivePrContext === 'function' ? getActivePrContext() : null + return Boolean(activePrContext?.prTitle) + } + + const getShouldShowEditedDesign = () => hasToken() && hasActivePrContext() + + const setRefreshHandlers = ({ syncHeaderLabels, renderWorkspaceTabs } = {}) => { + runRefresh = () => { + if (typeof syncHeaderLabels === 'function') { + syncHeaderLabels() + } + + if (typeof renderWorkspaceTabs === 'function') { + renderWorkspaceTabs() + } + } + } + + const refreshIndicators = () => { + runRefresh() + } + + return { + getShouldShowEditedDesign, + setRefreshHandlers, + refreshIndicators, + } +} + +export { createEditedIndicatorVisibilityController } diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js index a366ec5..99b26f5 100644 --- a/src/modules/app-core/github-workflows-setup.js +++ b/src/modules/app-core/github-workflows-setup.js @@ -34,6 +34,7 @@ const createGitHubWorkflowsSetup = ({ workspace.reconcileWorkspaceTabsWithPushUpdates, getActivePrContextSyncKey: runtime.getActivePrContextSyncKey, prContextUi: runtime.prContextUi, + onPrContextStateChange: runtime.onPrContextStateChange, getTokenForVisibility: runtime.getTokenForVisibility, closeWorkspacesDrawer: runtime.closeWorkspacesDrawer, getActivePrEditorSyncKey: runtime.getActivePrEditorSyncKey, diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index a88d0fc..85303f7 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -58,6 +58,7 @@ const initializeGitHubWorkflows = ({ reconcileWorkspaceTabsWithPushUpdates, getActivePrContextSyncKey, prContextUi, + onPrContextStateChange, getTokenForVisibility, closeWorkspacesDrawer, getActivePrEditorSyncKey, @@ -191,6 +192,10 @@ const initializeGitHubWorkflows = ({ if (activeContext) { closeWorkspacesDrawer() } + + if (typeof onPrContextStateChange === 'function') { + onPrContextStateChange(activeContext) + } }, onSyncActivePrEditorContent: async args => { const result = await prEditorSyncController.syncFromActiveContext(args) @@ -289,6 +294,9 @@ const initializeGitHubWorkflows = ({ prDrawerController.setSelectedRepository(githubAiContextState.selectedRepository) prDrawerController.syncRepositories() prContextUi.setActivePrContext(prDrawerController.getActivePrContext()) + if (typeof onPrContextStateChange === 'function') { + onPrContextStateChange(prDrawerController.getActivePrContext()) + } githubPrContextClose?.addEventListener('click', () => { if (!githubAiContextState.activePrContext) { diff --git a/src/modules/app-core/workspace-controllers-setup.js b/src/modules/app-core/workspace-controllers-setup.js index 371305a..49c9c6c 100644 --- a/src/modules/app-core/workspace-controllers-setup.js +++ b/src/modules/app-core/workspace-controllers-setup.js @@ -48,6 +48,7 @@ const createWorkspaceControllersSetup = ({ setHasPendingWorkspaceTabsRender, persistActiveTabEditorContent, getWorkspaceTabDisplay, + getShouldShowEditedDesign, workspaceTabsShell, workspaceTabAddWrap, setWorkspaceTabRenameState, @@ -178,6 +179,7 @@ const createWorkspaceControllersSetup = ({ finishWorkspaceTabRename: finishWorkspaceTabRenameDelegate, removeWorkspaceTab: removeWorkspaceTabDelegate, getWorkspaceTabDisplay, + getShouldShowEditedDesign, workspaceTabsShell, workspaceTabAddWrap, syncEditorFromActiveWorkspaceTab: syncEditorFromActiveWorkspaceTabDelegate, diff --git a/src/modules/app-core/workspace-editor-helpers.js b/src/modules/app-core/workspace-editor-helpers.js index fe0269e..c067b72 100644 --- a/src/modules/app-core/workspace-editor-helpers.js +++ b/src/modules/app-core/workspace-editor-helpers.js @@ -5,6 +5,7 @@ const createWorkspaceEditorHelpers = ({ editorPanelsByKind, editorHeaderLabelByKind, editorHeaderDirtyStatusByKind, + getShouldShowEditedDesign, defaultTabNameByKind, toNonEmptyWorkspaceText, getLoadedStylesTabId, @@ -54,7 +55,11 @@ const createWorkspaceEditorHelpers = ({ } if (dirtyStatusLabel instanceof HTMLElement) { - const isDirty = Boolean(tab?.isDirty) + const shouldShowEditedDesign = + typeof getShouldShowEditedDesign === 'function' + ? Boolean(getShouldShowEditedDesign()) + : true + const isDirty = shouldShowEditedDesign && Boolean(tab?.isDirty) dirtyStatusLabel.hidden = !isDirty if (isDirty) { dirtyStatusLabel.removeAttribute('aria-hidden') diff --git a/src/modules/app-core/workspace-tabs-renderer.js b/src/modules/app-core/workspace-tabs-renderer.js index c7e5569..56ba433 100644 --- a/src/modules/app-core/workspace-tabs-renderer.js +++ b/src/modules/app-core/workspace-tabs-renderer.js @@ -19,6 +19,7 @@ const createWorkspaceTabsRenderer = ({ finishWorkspaceTabRename, removeWorkspaceTab, getWorkspaceTabDisplay, + getShouldShowEditedDesign, workspaceTabsShell, workspaceTabAddWrap, syncEditorFromActiveWorkspaceTab, @@ -43,6 +44,10 @@ const createWorkspaceTabsRenderer = ({ try { const tabs = workspaceTabsState.getTabs() const activeTabId = workspaceTabsState.getActiveTabId() + const shouldShowEditedDesign = + typeof getShouldShowEditedDesign === 'function' + ? Boolean(getShouldShowEditedDesign()) + : true workspaceTabsStrip.replaceChildren() @@ -226,7 +231,7 @@ const createWorkspaceTabsRenderer = ({ tabContainer.append(metaBadge) } - if (tab.isDirty) { + if (shouldShowEditedDesign && tab.isDirty) { const dirtyBadge = document.createElement('span') dirtyBadge.className = 'workspace-tab__dirty-indicator' dirtyBadge.setAttribute('aria-hidden', 'true') From 465b71c2495013b0f6cfdc48eb3292b5f264c122 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 18 Apr 2026 13:37:31 -0500 Subject: [PATCH 3/5] refactor: improving gh syncs. --- playwright/github-pr-drawer.spec.ts | 572 +++++++++++++++++- src/app.js | 67 +- src/index.html | 4 +- .../app-core/github-workflows-setup.js | 2 + src/modules/app-core/github-workflows.js | 8 + .../publish-trailing-newline-normalizer.js | 79 +++ .../app-core/workspace-controllers-setup.js | 1 + .../app-core/workspace-local-helpers.js | 39 +- .../app-core/workspace-sync-controller.js | 17 +- .../workspace-tab-mutations-controller.js | 6 + src/modules/github/github-pr-drawer.js | 24 +- src/modules/workspace/workspace-storage.js | 4 + 12 files changed, 766 insertions(+), 57 deletions(-) create mode 100644 src/modules/app-core/publish-trailing-newline-normalizer.js diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index 45f896b..2cc5966 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -5,6 +5,7 @@ import type { PullRequestCreateBody, } from './helpers/app-test-helpers.js' import { + addWorkspaceTab, appEntryPath, connectByotWithSingleRepo, ensureOpenPrDrawerOpen, @@ -147,6 +148,71 @@ const getLocalContextOptionLabels = async (page: Page) => { .evaluateAll(nodes => nodes.map(node => node.textContent?.trim() || '')) } +const getWorkspaceTabsRecord = async ( + page: Page, + { headBranch = '' }: { headBranch?: string } = {}, +) => { + return page.evaluate( + async input => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) + ? getAllRequest.result + : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + const normalizedHead = + typeof input?.headBranch === 'string' + ? input.headBranch.trim().toLowerCase() + : '' + + if (normalizedHead) { + const matched = records.find(record => { + const headValue = + typeof record?.head === 'string' ? record.head.trim().toLowerCase() : '' + return headValue === normalizedHead + }) + + if (matched) { + return matched + } + } + + const sortedByLastModified = [...records].sort((left, right) => { + const leftModified = + typeof left?.lastModified === 'number' ? left.lastModified : 0 + const rightModified = + typeof right?.lastModified === 'number' ? right.lastModified : 0 + return rightModified - leftModified + }) + + return sortedByLastModified[0] ?? null + } finally { + db.close() + } + }, + { headBranch }, + ) +} + test('Open PR drawer confirms and submits component/styles filepaths', async ({ page, }) => { @@ -339,6 +405,208 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ ).toBeVisible() }) +test('Open PR success normalizes trailing newline without showing Edited indicators', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/Develop/Open-Pr-Test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 62, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/62', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + await setComponentEditorSource(page, 'const App = () => ') + await setStylesEditorSource(page, '.button { color: red; }') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('PR title').fill('Normalize trailing newline after open PR') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/62', + ) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'Develop/Open-Pr-Test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const componentTab = tabs.find(tab => tab?.id === 'component') + const stylesTab = tabs.find(tab => tab?.id === 'styles') + + const componentContent = + typeof componentTab?.content === 'string' ? componentTab.content : '' + const stylesContent = + typeof stylesTab?.content === 'string' ? stylesTab.content : '' + + return { + componentHasTrailingNewline: componentContent.endsWith('\n'), + stylesHasTrailingNewline: stylesContent.endsWith('\n'), + componentNotDirty: componentTab?.isDirty === false, + stylesNotDirty: stylesTab?.isDirty === false, + componentSynced: componentTab?.syncedContent === componentContent, + stylesSynced: stylesTab?.syncedContent === stylesContent, + } + }, + { timeout: 10_000 }, + ) + .toEqual({ + componentHasTrailingNewline: true, + stylesHasTrailingNewline: true, + componentNotDirty: true, + stylesNotDirty: true, + componentSynced: true, + stylesSynced: true, + }) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect( + page + .getByRole('listitem', { name: 'Workspace tab app.css' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() + await expect(page.locator('#styles-dirty-status')).toBeHidden() + + const treePayload = treeRequests[0]?.tree as Array> + const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') + const stylesBlob = treePayload?.find(file => file.path === 'src/styles/app.css') + expect(typeof componentBlob?.content).toBe('string') + expect(typeof stylesBlob?.content).toBe('string') + expect(String(componentBlob?.content).endsWith('\n')).toBe(true) + expect(String(stylesBlob?.content).endsWith('\n')).toBe(true) +}) + test('Open PR drawer can filter stored local contexts by search', async ({ page }) => { await waitForAppReady(page, `${appEntryPath}`) @@ -1734,17 +2002,13 @@ test('Active PR context uses Push commit flow without creating a new pull reques await expect(page.getByLabel('Pull request base branch')).toBeDisabled() await expect(page.getByLabel('Head')).toHaveJSProperty('readOnly', true) await expect(page.getByLabel('PR title')).toHaveJSProperty('readOnly', true) - await expect( - page.getByLabel('Include entry tab source in committed output'), - ).toBeEnabled() + await expect(page.getByLabel('Include entry tab')).toBeEnabled() await expect(page.getByLabel('Commit message')).toBeEditable() await expect(page.getByLabel('PR description')).toBeHidden() await expect(page.getByLabel('Commit message')).toBeVisible() - const includeWrapperToggle = page.getByLabel( - 'Include entry tab source in committed output', - ) + const includeWrapperToggle = page.getByLabel('Include entry tab') await expect(includeWrapperToggle).toBeEnabled() await includeWrapperToggle.check() await expect(includeWrapperToggle).toBeChecked() @@ -1786,6 +2050,262 @@ test('Active PR context uses Push commit flow without creating a new pull reques expect(contentsPutRequests).toHaveLength(0) }) +test('Active PR context push with no local changes shows neutral status', async ({ + page, +}) => { + const updateRefRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + if (route.request().method() === 'PATCH') { + updateRefRequests.push(route.request().postDataJSON() as Record) + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + JSON.stringify({ + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], + renderMode: 'react', + baseBranch: 'main', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prBody: 'Saved body', + isActivePr: true, + pullRequestNumber: 2, + pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', + }), + ) + }) + + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await setComponentEditorSource(page, 'const commitMarker = 2') + await ensureOpenPrDrawerOpen(page) + + await page.getByRole('button', { name: 'Push commit' }).last().click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Push commit' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Commit pushed to develop/open-pr-test') + expect(updateRefRequests).toHaveLength(1) + + await ensureOpenPrDrawerOpen(page) + + await page.getByRole('button', { name: 'Push commit' }).last().click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('No local editor changes to push.') + await expect(page.locator('#clear-confirm-dialog')).toBeHidden() +}) + +test('New workspace tabs show Edited indicator in active PR context', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + JSON.stringify({ + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], + renderMode: 'react', + baseBranch: 'main', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prBody: 'Saved body', + isActivePr: true, + pullRequestNumber: 2, + pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', + }), + ) + }) + + await connectByotWithSingleRepo(page) + await addWorkspaceTab(page) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab module.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(1) +}) + test('Active PR context push commit uses Git Database API atomic path by default', async ({ page, }) => { @@ -2393,28 +2913,26 @@ test('Open PR drawer confirmation does not report path traversal errors', async ).not.toContainText('File path cannot include parent directory traversal.') }) -test('Open PR drawer include App wrapper checkbox defaults off and resets on reopen', async ({ +test('Open PR drawer include entry tab checkbox defaults on and resets on reopen', async ({ page, }) => { await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await ensureOpenPrDrawerOpen(page) - const includeWrapperToggle = page.getByLabel( - 'Include entry tab source in committed output', - ) - await expect(includeWrapperToggle).not.toBeChecked() - - await includeWrapperToggle.check() + const includeWrapperToggle = page.getByLabel('Include entry tab') await expect(includeWrapperToggle).toBeChecked() + await includeWrapperToggle.uncheck() + await expect(includeWrapperToggle).not.toBeChecked() + await page.getByRole('button', { name: 'Close open pull request drawer' }).click() await ensureOpenPrDrawerOpen(page) - await expect(includeWrapperToggle).not.toBeChecked() + await expect(includeWrapperToggle).toBeChecked() }) -test('Open PR drawer strips App wrapper from committed component source by default', async ({ +test('Open PR drawer includes App wrapper in committed component source by default', async ({ page, }) => { const treeRequests: Array> = [] @@ -2550,7 +3068,7 @@ test('Open PR drawer strips App wrapper from committed component source by defau await ensureOpenPrDrawerOpen(page) await page.getByLabel('Head').fill('develop/repo/editor-sync-without-app') - await page.getByLabel('PR title').fill('Strip App wrapper by default') + await page.getByLabel('PR title').fill('Include App wrapper by default') await submitOpenPrAndConfirm(page) await expect( @@ -2562,13 +3080,13 @@ test('Open PR drawer strips App wrapper from committed component source by defau const treePayload = treeRequests[0]?.tree as Array> const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') expect(componentBlob?.content).toEqual(expect.any(String)) - const strippedComponentSource = String(componentBlob?.content) + const fullComponentSource = String(componentBlob?.content) - expect(strippedComponentSource).toContain('const CounterButton = () =>') - expect(strippedComponentSource).not.toContain('const App = () =>') + expect(fullComponentSource).toContain('const CounterButton = () =>') + expect(fullComponentSource).toContain('const App = () =>') }) -test('Open PR drawer includes App wrapper in committed source when toggled on', async ({ +test('Open PR drawer strips App wrapper from committed source when toggled off', async ({ page, }) => { const treeRequests: Array> = [] @@ -2704,13 +3222,11 @@ test('Open PR drawer includes App wrapper in committed source when toggled on', ) await ensureOpenPrDrawerOpen(page) - const includeWrapperToggle = page.getByLabel( - 'Include entry tab source in committed output', - ) - await includeWrapperToggle.check() + const includeWrapperToggle = page.getByLabel('Include entry tab') + await includeWrapperToggle.uncheck() await page.getByLabel('Head').fill('develop/repo/editor-sync-with-app') - await page.getByLabel('PR title').fill('Include App wrapper in commit') + await page.getByLabel('PR title').fill('Strip App wrapper in commit') await submitOpenPrAndConfirm(page) await expect( @@ -2722,7 +3238,7 @@ test('Open PR drawer includes App wrapper in committed source when toggled on', const treePayload = treeRequests[0]?.tree as Array> const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') expect(componentBlob?.content).toEqual(expect.any(String)) - const fullComponentSource = String(componentBlob?.content) - expect(fullComponentSource).toContain('const CounterButton = () =>') - expect(fullComponentSource).toContain('const App = () =>') + const strippedComponentSource = String(componentBlob?.content) + expect(strippedComponentSource).toContain('const CounterButton = () =>') + expect(strippedComponentSource).not.toContain('const App = () =>') }) diff --git a/src/app.js b/src/app.js index 5f6a3db..7c2a123 100644 --- a/src/app.js +++ b/src/app.js @@ -30,6 +30,7 @@ import { } from './modules/app-core/workspace-local-helpers.js' import { createWorkspaceEditorHelpers } from './modules/app-core/workspace-editor-helpers.js' import { createEditedIndicatorVisibilityController } from './modules/app-core/edited-indicator-visibility-controller.js' +import { createPublishTrailingNewlineNormalizer } from './modules/app-core/publish-trailing-newline-normalizer.js' import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js' import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js' import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js' @@ -393,6 +394,8 @@ const githubAiContextState = { hasSyncedActivePrEditorContent: false, } +let workspacePrContextState = 'inactive' + let chatDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, @@ -492,11 +495,16 @@ const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.g const getCurrentSelectedRepository = () => githubAiContextState.selectedRepository ?? byotControls.getSelectedRepository() +const getCurrentSelectedRepositoryFullName = () => + getCurrentSelectedRepository()?.fullName ?? '' + const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ - getCurrentSelectedRepository, + getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, githubPrBaseBranch, githubPrHeadBranch, githubPrTitle, + getActivePrContext: () => githubAiContextState.activePrContext, + getPrContextState: () => workspacePrContextState, }) let loadedComponentTabId = 'component' @@ -597,11 +605,8 @@ const ensureWorkspaceTabsShape = createEnsureWorkspaceTabsShape({ const buildWorkspaceTabsSnapshot = () => workspaceSyncController.buildWorkspaceTabsSnapshot() -const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => - workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) - -const getWorkspacePrFileCommits = () => - workspaceSyncController.getWorkspacePrFileCommits() +const getWorkspacePrFileCommits = options => + workspaceSyncController.getWorkspacePrFileCommits(options) const getEditorSyncTargets = () => workspaceSyncController.getEditorSyncTargets() @@ -634,7 +639,7 @@ const { getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, setActiveWorkspaceRecordId: value => (activeWorkspaceRecordId = value), setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), - getCurrentSelectedRepository, + getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value), ensureWorkspaceTabsShape, @@ -700,6 +705,41 @@ editedIndicatorVisibilityController.setRefreshHandlers({ renderWorkspaceTabs, }) +const normalizeWorkspaceEditorsTrailingNewlineAfterPublish = + createPublishTrailingNewlineNormalizer({ + workspaceTabsState, + getLoadedTabIds: () => [loadedComponentTabId, loadedStylesTabId], + getJsxSource: () => getJsxSource(), + getCssSource: () => getCssSource(), + setJsxSource, + setCssSource, + setSuppressEditorChangeSideEffects: value => { + suppressEditorChangeSideEffects = value + }, + queueWorkspaceSave: () => queueWorkspaceSave(), + }) + +const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => { + normalizeWorkspaceEditorsTrailingNewlineAfterPublish() + return workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) +} + +const setWorkspacePrContextState = nextState => { + if (typeof nextState !== 'string' || !nextState.trim()) { + return + } + + workspacePrContextState = nextState.trim() +} + +const persistWorkspacePrContextState = nextState => { + setWorkspacePrContextState(nextState) + queueWorkspaceSave() + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) +} + const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, @@ -774,9 +814,20 @@ const githubWorkflows = createGitHubWorkflowsSetup({ getStyleMode: () => styleMode.value, getActivePrContextSyncKey, prContextUi, - onPrContextStateChange: () => { + onPrContextStateChange: activeContext => { + if (activeContext?.prTitle) { + setWorkspacePrContextState('active') + } else if (workspacePrContextState === 'active') { + setWorkspacePrContextState('inactive') + } editedIndicatorVisibilityController.refreshIndicators() }, + onPrContextClosed: () => { + persistWorkspacePrContextState('closed') + }, + onPrContextDisconnected: () => { + persistWorkspacePrContextState('disconnected') + }, getTokenForVisibility: () => githubAiContextState.token, closeWorkspacesDrawer: () => { void workspacesDrawerController?.setOpen(false) diff --git a/src/index.html b/src/index.html index 18fa2fb..d625d9a 100644 --- a/src/index.html +++ b/src/index.html @@ -920,8 +920,8 @@

Open Pull Request

class="github-pr-field github-pr-field--full github-pr-field--checkbox" for="github-pr-include-app-wrapper" > - - Include entry tab source in committed output + + Include entry tab