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
148 changes: 148 additions & 0 deletions playwright/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,154 @@ test('Open PR drawer base dropdown updates from mocked repo branches', async ({
).toBe(true)
})

test('Open PR drawer keeps a single active PR context in localStorage', 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: 2,
owner: { login: 'knightedcodemonkey' },
name: 'develop',
full_name: 'knightedcodemonkey/develop',
default_branch: 'main',
permissions: { push: true },
},
{
id: 1,
owner: { login: 'knightedcodemonkey' },
name: 'css',
full_name: 'knightedcodemonkey/css',
default_branch: 'stable',
permissions: { push: true },
},
]),
})
})

await mockRepositoryBranches(page, {
'knightedcodemonkey/develop': ['main', 'develop-next'],
'knightedcodemonkey/css': ['stable', 'release/1.x'],
})

await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)

await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
await page.locator('#github-token-add').click()
await ensureOpenPrDrawerOpen(page)

const repoSelect = page.locator('#github-pr-repo-select')
const componentPath = page.locator('#github-pr-component-path')

await repoSelect.selectOption('knightedcodemonkey/develop')
await componentPath.fill('examples/develop/App.tsx')
await componentPath.blur()

await repoSelect.selectOption('knightedcodemonkey/css')
await componentPath.fill('examples/css/App.tsx')
await componentPath.blur()

const activeContext = await page.evaluate(() => {
const storagePrefix = 'knighted:develop:github-pr-config:'
const keys = Object.keys(localStorage).filter(key => key.startsWith(storagePrefix))
const key = keys[0] ?? null
const raw = key ? localStorage.getItem(key) : null

let parsed = null
try {
parsed = raw ? JSON.parse(raw) : null
} catch {
parsed = null
}

return { keys, key, parsed }
})

expect(activeContext.keys).toHaveLength(1)
expect(activeContext.key).toBe(
'knighted:develop:github-pr-config:knightedcodemonkey/css',
)
expect(activeContext.parsed?.componentFilePath).toBe('examples/css/App.tsx')
})

test('Open PR drawer does not prune saved PR context on repo switch before save', 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: 2,
owner: { login: 'knightedcodemonkey' },
name: 'develop',
full_name: 'knightedcodemonkey/develop',
default_branch: 'main',
permissions: { push: true },
},
{
id: 1,
owner: { login: 'knightedcodemonkey' },
name: 'css',
full_name: 'knightedcodemonkey/css',
default_branch: 'stable',
permissions: { push: true },
},
]),
})
})

await mockRepositoryBranches(page, {
'knightedcodemonkey/develop': ['main', 'develop-next'],
'knightedcodemonkey/css': ['stable', 'release/1.x'],
})

await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)

await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
await page.locator('#github-token-add').click()
await ensureOpenPrDrawerOpen(page)

const repoSelect = page.locator('#github-pr-repo-select')
const componentPath = page.locator('#github-pr-component-path')

await repoSelect.selectOption('knightedcodemonkey/develop')
await componentPath.fill('examples/develop/App.tsx')
await componentPath.blur()

await repoSelect.selectOption('knightedcodemonkey/css')

const contexts = await page.evaluate(() => {
const storagePrefix = 'knighted:develop:github-pr-config:'
const keys = Object.keys(localStorage)
.filter(key => key.startsWith(storagePrefix))
.sort((left, right) => left.localeCompare(right))

return keys.map(key => {
const raw = localStorage.getItem(key)
let parsed = null

try {
parsed = raw ? JSON.parse(raw) : null
} catch {
parsed = null
}

return { key, parsed }
})
})

expect(contexts).toHaveLength(1)
expect(contexts[0]?.key).toBe(
'knighted:develop:github-pr-config:knightedcodemonkey/develop',
)
expect(contexts[0]?.parsed?.componentFilePath).toBe('examples/develop/App.tsx')
})

test('Open PR drawer validates unsafe filepaths', async ({ page }) => {
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
await connectByotWithSingleRepo(page)
Expand Down
38 changes: 34 additions & 4 deletions src/modules/github-pr-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ const defaultPrConfig = {
stylesFilePath: 'src/styles/app.css',
}

const pruneRepositoryPrConfigs = repositoryFullName => {
if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) {
return
}

const activeStorageKey = `${prConfigStoragePrefix}${repositoryFullName}`

try {
const keysToRemove = []

for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index)
if (!key || !key.startsWith(prConfigStoragePrefix)) {
continue
}

if (key !== activeStorageKey) {
keysToRemove.push(key)
}
}

for (const key of keysToRemove) {
localStorage.removeItem(key)
}
} catch {
/* noop */
}
}

const readRepositoryPrConfig = repositoryFullName => {
if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) {
return {}
Expand All @@ -31,10 +60,11 @@ const saveRepositoryPrConfig = ({ repositoryFullName, config }) => {
}

try {
localStorage.setItem(
`${prConfigStoragePrefix}${repositoryFullName}`,
JSON.stringify(config),
)
const activeStorageKey = `${prConfigStoragePrefix}${repositoryFullName}`

localStorage.setItem(activeStorageKey, JSON.stringify(config))

pruneRepositoryPrConfigs(repositoryFullName)
} catch {
/* noop */
}
Expand Down
6 changes: 4 additions & 2 deletions src/modules/type-diagnostics.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ export const createTypeDiagnosticsController = ({
return reactTypeLoadPromise
}

reactTypeLoadPromise = (async () => {
const loadReactTypeFiles = async () => {
const files = new Map()
const packageEntryByName = new Map()
const packageManifestByName = new Map()
Expand Down Expand Up @@ -631,7 +631,9 @@ export const createTypeDiagnosticsController = ({
files,
packageEntries: packageEntryByName,
}
})()
}

reactTypeLoadPromise = loadReactTypeFiles()

try {
return await reactTypeLoadPromise
Expand Down
4 changes: 2 additions & 2 deletions src/styles/ai-controls.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@
opacity: 0.9;
background: transparent;
padding: 0;
width: 24px;
height: 24px;
width: 20px;
height: 20px;
font-size: 0.74rem;
transition:
border-color 140ms ease,
Expand Down
7 changes: 3 additions & 4 deletions src/styles/dialogs-overlays.css
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@

.app-toast {
position: fixed;
top: 76px;
top: auto;
bottom: 12px;
right: 24px;
max-width: min(560px, calc(100vw - 48px));
border-radius: 12px;
Expand All @@ -115,7 +116,7 @@
line-height: 1.35;
z-index: 140;
opacity: 0;
transform: translateY(-6px);
transform: translateY(6px);
transition:
opacity 170ms ease,
transform 170ms ease;
Expand All @@ -129,8 +130,6 @@

@media (max-width: 900px) {
.app-toast {
top: auto;
bottom: 12px;
left: 12px;
right: 12px;
max-width: none;
Expand Down