mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
* feat(frontend): chat-scoped unified session changes bar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(frontend): drop diff-baseline toggle, show natural per-row diffs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): carry Draft marker to expanded raw-app file rows Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): show raw-app Draft badge once at tree root, not per file Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(frontend): reuse shared DraftBadge in session diff drawer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): show draft-author avatars in session diff badge, icon-only in sidebar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): keep badge pill around avatar in icon-only DraftBadge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): small draft marker = indigo pen + avatar; correct itemKind label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(frontend): drop package-lock churn from merge (match origin/main) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): dedup diff-button count for legacy fork sessions; test mask helper Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): collapsible session diff panel + per-row open-diff action Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): shared sessionDeployModel for review & deploy (S1) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): model-driven session review drawer, deploy inert (S2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): wire session deploy + on-behalf/conflict gating (S3) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): behind banner + Update fork + deployment request (S4) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): session changes dock opens drawer by filter (S5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): session review UI polish (badge, dock, In parent, tree width) - draft rows show only the avatar DraftBadge, not a duplicate state pill - drop redundant dock Review button (same as "N to review") - rename Done -> In parent with a "deployed in parent workspace" tooltip - widen the file tree Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): existence-gate In parent rows; Badge filters; badge hover - drop discarded mask-only items from the In-parent segment (existence check) - use the Badge component for the drawer filter segments and the changes dock - soften the blue Badge hover (blue-50 base was jumping to blue-200) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): polish session diff drawer (layout, badges, actions) - remove empty fork-banner gap; uniform sidebar tree padding - full-bleed diff list: drop card borders/side padding, separators between items - clamp tree x-overflow; right-align tree badges (min-w-0 on the row button) - brand-compliant selected filter badges; smaller draft badge - hide per-row open-diff button when the panel is open - rename "Delete draft" to "Discard draft" (destructive); remove header Review button - larger sm deploy/discard action buttons; remove per-item diff-content collapse Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): session Edits dock — deploy gating + change-op tracking Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): session bar per-status badges; drop change-op tracking Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): unwrap raw apps into per-file tree in session diff sidebar Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): dot-parcours pipeline (badge-derived, melt tooltip) + discard confirm Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): behind-only session item reads as deployed, not bare Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(frontend): even sidebar tree margins; gutter-aware right padding Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): pass chat id as from_session; wire deploying flag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): session diff drawer scroll-to-flush, ordering, spacer, deploy gating Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): preserve chat mask on compact; guard stale existence checks; clear poll timers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): refresh bar after drawer deploys; conflict hint over chip; plain conflict badge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(frontend): diff drawer card layout with flash ring and aligned insets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): hide stale deployed chip once row status badge reads deployed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(frontend): session dock to two states; drop parent deploy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): staged deploy animation in session edits drawer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): stale-draft warning in session edits drawer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): keep chat mask honest on deploy and discard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): map trigger_email deploy kind; serialize mask persists Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): reset mask on new chat; close review-flagged races Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(frontend): rename session drawer title to Edited during session Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): keep mask persist queue alive after a failed save Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): sync session chatId on chat rotation; gate deploy on canWrite Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): keep compare handoff for deletion-only session edits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): hold deploy success beat across re-keyed rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
6.3 KiB
TypeScript
166 lines
6.3 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import { buildDiffTree, type AppRootMeta, type DiffTreeItem, type NavEntry } from './diffTree'
|
||
|
||
// A diff item identified by its storage `key` but placed in the tree by its
|
||
// friendly `structurePath` — mirroring how the drawer feeds raw-app files.
|
||
function item(key: string, structurePath: string): DiffTreeItem<string> {
|
||
return { key, structurePath, data: key }
|
||
}
|
||
|
||
const noApps = new Map<string, AppRootMeta>()
|
||
const allOpen = () => true
|
||
|
||
function orderKeys(model: ReturnType<typeof buildDiffTree<string>>, isOpen = allOpen) {
|
||
return model.order(isOpen).map((e) => e.key)
|
||
}
|
||
|
||
describe('buildDiffTree — structure', () => {
|
||
it('groups items by their 2-segment scope', () => {
|
||
const t = buildDiffTree([item('a', 'u/admin/foo'), item('b', 'u/admin/bar')], noApps)
|
||
expect(t.root.children).toHaveLength(1)
|
||
const scope = t.root.children[0]
|
||
expect(scope.type).toBe('folder')
|
||
if (scope.type !== 'folder') return
|
||
expect(scope.fullPath).toBe('u/admin')
|
||
expect(scope.isScope).toBe(true)
|
||
expect(scope.children.map((c) => c.type)).toEqual(['file', 'file'])
|
||
})
|
||
|
||
it('nests deeper segments as folders and sorts folders-first then alphabetically', () => {
|
||
const t = buildDiffTree(
|
||
[item('z', 'u/admin/zeta'), item('a', 'u/admin/alpha'), item('s', 'u/admin/sub/leaf')],
|
||
noApps
|
||
)
|
||
const scope = t.root.children[0]
|
||
if (scope.type !== 'folder') throw new Error('expected scope folder')
|
||
// folder ('sub') before files, files alphabetical
|
||
expect(scope.children.map((c) => `${c.type}:${c.name}`)).toEqual([
|
||
'folder:sub',
|
||
'file:alpha',
|
||
'file:zeta'
|
||
])
|
||
})
|
||
|
||
it('keeps a single-segment item at the root with no scope', () => {
|
||
const t = buildDiffTree([item('orphan', 'orphan')], noApps)
|
||
expect(t.root.children).toHaveLength(1)
|
||
expect(t.root.children[0]).toMatchObject({ type: 'file', name: 'orphan', key: 'orphan' })
|
||
})
|
||
|
||
it('app roots sort as items among leaves — same position before and after unwrap', () => {
|
||
const apps = new Map<string, AppRootMeta>([['u/admin/beta_app', { summaryKey: 'raw_app/b' }]])
|
||
// Unloaded: the app is a plain leaf between alpha and zeta.
|
||
const before = buildDiffTree(
|
||
[
|
||
item('a', 'u/admin/alpha'),
|
||
item('raw_app/b', 'u/admin/beta_app'),
|
||
item('z', 'u/admin/zeta')
|
||
],
|
||
apps
|
||
)
|
||
const scopeBefore = before.root.children[0]
|
||
if (scopeBefore.type !== 'folder') throw new Error('expected scope')
|
||
expect(scopeBefore.children.map((c) => `${c.type}:${c.name}`)).toEqual([
|
||
'file:alpha',
|
||
'file:beta_app',
|
||
'file:zeta'
|
||
])
|
||
// Loaded: the leaf is replaced by the app-root folder — same position,
|
||
// not hoisted to the folder group.
|
||
const after = buildDiffTree(
|
||
[
|
||
item('a', 'u/admin/alpha'),
|
||
item('f', 'u/admin/beta_app/App.tsx'),
|
||
item('z', 'u/admin/zeta')
|
||
],
|
||
apps
|
||
)
|
||
const scopeAfter = after.root.children[0]
|
||
if (scopeAfter.type !== 'folder') throw new Error('expected scope')
|
||
expect(scopeAfter.children.map((c) => `${c.type}:${c.name}`)).toEqual([
|
||
'file:alpha',
|
||
'folder:beta_app',
|
||
'file:zeta'
|
||
])
|
||
})
|
||
|
||
it('tags the folder matching an app root and leaves others untagged', () => {
|
||
const apps = new Map<string, AppRootMeta>([
|
||
['u/admin/my_app', { summaryKey: 'raw_app/x', summary: 'My app' }]
|
||
])
|
||
const t = buildDiffTree(
|
||
[item('f1', 'u/admin/my_app/App.tsx'), item('f2', 'u/admin/other/x.ts')],
|
||
apps
|
||
)
|
||
const scope = t.root.children[0]
|
||
if (scope.type !== 'folder') throw new Error('expected scope')
|
||
const myApp = scope.children.find((c) => c.type === 'folder' && c.fullPath === 'u/admin/my_app')
|
||
const other = scope.children.find((c) => c.type === 'folder' && c.fullPath === 'u/admin/other')
|
||
expect(myApp && myApp.type === 'folder' && myApp.app?.summary).toBe('My app')
|
||
expect(other && other.type === 'folder' && other.app).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('buildDiffTree — order(isOpen)', () => {
|
||
const t = buildDiffTree(
|
||
[item('a', 'u/admin/app/App.tsx'), item('b', 'u/admin/app/index.css')],
|
||
new Map([['u/admin/app', { summaryKey: 'raw_app/app' }]])
|
||
)
|
||
|
||
it('lists scope → app folder → files when everything is open', () => {
|
||
expect(orderKeys(t)).toEqual(['folder:u/admin', 'folder:u/admin/app', 'a', 'b'])
|
||
})
|
||
|
||
it('hides a folder’s children when it is collapsed', () => {
|
||
const closedApp = (key: string) => key !== 'folder:u/admin/app'
|
||
expect(orderKeys(t, closedApp)).toEqual(['folder:u/admin', 'folder:u/admin/app'])
|
||
})
|
||
})
|
||
|
||
describe('buildDiffTree — parentKeyOf (ArrowLeft)', () => {
|
||
// The regression that shipped: a raw-app file keyed on its STORAGE path
|
||
// (`…/draft_<uuid>/…`) but placed by its FRIENDLY path. The parent must be the
|
||
// friendly folder it actually nests under — not anything derived from the key.
|
||
it('returns the friendly parent folder for a storage-keyed file', () => {
|
||
const t = buildDiffTree(
|
||
[item('raw_app_file/u/admin/draft_x/App.tsx', 'u/admin/my_app/App.tsx')],
|
||
noApps
|
||
)
|
||
expect(t.parentKeyOf('raw_app_file/u/admin/draft_x/App.tsx')).toBe('folder:u/admin/my_app')
|
||
})
|
||
|
||
it('returns the immediate folder for a deeper file', () => {
|
||
const t = buildDiffTree([item('k', 'u/admin/app/runnables/a')], noApps)
|
||
expect(t.parentKeyOf('k')).toBe('folder:u/admin/app/runnables')
|
||
expect(t.parentKeyOf('folder:u/admin/app/runnables')).toBe('folder:u/admin/app')
|
||
})
|
||
|
||
it('returns the scope folder for a file directly under it', () => {
|
||
const t = buildDiffTree([item('k', 'u/alice/script')], noApps)
|
||
expect(t.parentKeyOf('k')).toBe('folder:u/alice')
|
||
})
|
||
|
||
it('returns undefined for a scope folder and a root-level leaf', () => {
|
||
const t = buildDiffTree([item('k', 'u/admin/app/App.tsx'), item('o', 'orphan')], noApps)
|
||
expect(t.parentKeyOf('folder:u/admin')).toBeUndefined()
|
||
expect(t.parentKeyOf('o')).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('buildDiffTree — firstChildKeyOf (ArrowRight)', () => {
|
||
it('returns the first child key (folders sort first)', () => {
|
||
const t = buildDiffTree(
|
||
[item('file', 'u/admin/app/App.tsx'), item('run', 'u/admin/app/runnables/a')],
|
||
noApps
|
||
)
|
||
// 'runnables' folder sorts before the 'App.tsx' file
|
||
expect(t.firstChildKeyOf('folder:u/admin/app')).toBe('folder:u/admin/app/runnables')
|
||
expect(t.firstChildKeyOf('folder:u/admin/app/runnables')).toBe('run')
|
||
})
|
||
|
||
it('returns undefined for an unknown or empty folder', () => {
|
||
const t = buildDiffTree([item('k', 'u/admin/app/App.tsx')], noApps)
|
||
expect(t.firstChildKeyOf('folder:does/not/exist')).toBeUndefined()
|
||
})
|
||
})
|