Files
windmill/frontend/src/lib/components/sessions/diffTree.ts
T
GuilhemandClaude Opus 4.8 a6c0b3756b feat: chat-scoped session changes bar + unified diff drawer (#9762)
* 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>
2026-07-06 09:23:54 +02:00

174 lines
5.9 KiB
TypeScript

// Pure file-tree model for the diff drawer (WorkspaceDiffDrawer).
//
// Turns a flat list of diff items into a navigable folder tree and answers the
// keyboard-navigation queries the drawer needs (visible order, parent, first
// child). It is generic over the leaf payload `T` on purpose: the drawer's
// `DiffRow` type lives in WorkspaceDiffDrawer, which its two adapters import —
// importing it here would make a cycle.
//
// Items are grouped by "scope" (the first two path segments, e.g. `f/foo` or
// `u/alice`); deeper segments become nested folders. Folder keys are
// `folder:<fullPath>`. The crucial invariant: every structural answer
// (parent/first-child/order) comes from the tree as it was *built*, never from
// re-splitting a path at the call site — so a node's tree position and its
// navigation parent can never drift apart.
//
// Two path spaces meet here. `structurePath` decides tree placement (it's the
// friendly path, so a never-deployed draft nests under `u/me/my_app` rather
// than `u/me/draft_<uuid>`); `key` is the caller's stable identity (the storage
// key — used for the load cache, the row id and `data-nav-key`). Structure code
// only ever reads `structurePath`; it carries `key` through opaquely.
export type DiffTreeItem<T> = {
/** Stable identity (storage key). Becomes the file node's key + nav key. */
key: string
/** Friendly path that decides where the item sits in the tree. */
structurePath: string
data: T
}
/** Marks a folder as a raw app's root so the drawer renders its header as the
* raw-app row instead of a plain folder. Keyed by the app's friendly path. */
export type AppRootMeta = {
summaryKey: string
summary?: string
hasDraft?: boolean
draftOnly?: boolean
draftUsers?: { username?: string | null }[]
draftItemKind?: import('$lib/gen').UserDraftItemKind
}
export type FolderNode<T> = {
type: 'folder'
name: string
fullPath: string
isScope: boolean
key: string
children: TreeNode<T>[]
app?: AppRootMeta
}
export type FileNode<T> = { type: 'file'; name: string; key: string; data: T }
export type TreeNode<T> = FolderNode<T> | FileNode<T>
export type NavEntry<T> =
| { type: 'folder'; key: string; node: FolderNode<T> }
| { type: 'file'; key: string; data: T }
export type DiffTreeModel<T> = {
root: FolderNode<T>
/** Visible entries top-to-bottom, honoring the caller's fold state. */
order(isOpen: (key: string) => boolean): NavEntry<T>[]
/** Folder key containing `key`, or undefined at the top (no parent folder). */
parentKeyOf(key: string): string | undefined
/** Key of a folder's first child (ArrowRight target), or undefined if empty. */
firstChildKeyOf(folderKey: string): string | undefined
}
export function folderKeyFor(fullPath: string): string {
return `folder:${fullPath}`
}
export function buildDiffTree<T>(
items: DiffTreeItem<T>[],
appMeta: Map<string, AppRootMeta>
): DiffTreeModel<T> {
const root: FolderNode<T> = {
type: 'folder',
name: '',
fullPath: '',
isScope: false,
key: folderKeyFor(''),
children: []
}
const folderCache = new Map<string, FolderNode<T>>()
const nodeByKey = new Map<string, FolderNode<T>>()
// child key → parent folder key. Direct children of `root` are intentionally
// absent (they have no navigable parent), so parentKeyOf returns undefined
// for scope folders and root-level leaves — matching ArrowLeft's old behavior.
const parentByKey = new Map<string, string>()
function getFolder(parent: FolderNode<T>, fullPath: string, name: string, isScope: boolean) {
let f = folderCache.get(fullPath)
if (!f) {
f = { type: 'folder', name, fullPath, isScope, key: folderKeyFor(fullPath), children: [] }
folderCache.set(fullPath, f)
nodeByKey.set(f.key, f)
parent.children.push(f)
if (parent !== root) parentByKey.set(f.key, parent.key)
}
return f
}
function addFile(parent: FolderNode<T>, name: string, it: DiffTreeItem<T>) {
parent.children.push({ type: 'file', name, key: it.key, data: it.data })
if (parent !== root) parentByKey.set(it.key, parent.key)
}
for (const it of items) {
const parts = it.structurePath.split('/')
if (parts.length < 2) {
addFile(root, it.structurePath, it)
continue
}
const scopeKey = parts.slice(0, 2).join('/')
const scope = getFolder(root, scopeKey, scopeKey, true)
if (parts.length === 2) {
addFile(scope, parts[1], it)
continue
}
const rest = parts.slice(2)
let parent = scope
let fkey = scopeKey
for (let i = 0; i < rest.length - 1; i++) {
fkey = `${fkey}/${rest[i]}`
parent = getFolder(parent, fkey, rest[i], false)
}
addFile(parent, rest[rest.length - 1], it)
}
// Tag the folder matching each raw app's friendly path as its app root.
// Must happen before sorting: app roots sort as items, not as folders.
for (const [fp, meta] of appMeta) {
const f = folderCache.get(fp)
if (f) f.app = meta
}
// Path folders group first; everything item-like — leaves AND app roots —
// sorts together by name, so a raw app keeps its position when expanding
// turns its leaf row into an app-root folder.
const isGrouping = (n: TreeNode<T>) => n.type === 'folder' && !n.app
const sortRec = (n: FolderNode<T>) => {
n.children.sort((a, b) => {
const ga = isGrouping(a) ? 0 : 1
const gb = isGrouping(b) ? 0 : 1
if (ga !== gb) return ga - gb
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
return {
root,
order(isOpen) {
const out: NavEntry<T>[] = []
const walk = (n: TreeNode<T>) => {
if (n.type === 'file') {
out.push({ type: 'file', key: n.key, data: n.data })
return
}
out.push({ type: 'folder', key: n.key, node: n })
if (isOpen(n.key)) for (const c of n.children) walk(c)
}
for (const c of root.children) walk(c)
return out
},
parentKeyOf(key) {
return parentByKey.get(key)
},
firstChildKeyOf(folderKey) {
return nodeByKey.get(folderKey)?.children[0]?.key
}
}
}