Merge remote-tracking branch 'origin/main' into feat/changes-md-reading
# Conflicts: # src/client/changes/DiffPane.tsx # src/client/locales-ar.ts # src/client/locales-de.ts # src/client/locales-fr.ts # src/client/locales-hi.ts # src/client/locales-id.ts # src/client/locales-it.ts # src/client/locales-ja.ts # src/client/locales-ko.ts # src/client/locales-nl.ts # src/client/locales-pl.ts # src/client/locales-pt.ts # src/client/locales-ru.ts # src/client/locales-sv.ts # src/client/locales-th.ts # src/client/locales-tr.ts # src/client/locales-vi.ts # src/client/locales-zh-HK.ts # src/client/locales-zh-MO.ts # src/client/locales-zh-TW.ts
This commit is contained in:
@@ -18,6 +18,7 @@ import type { SidebarSessionEvent } from '../../context-types.ts'
|
||||
import type { TabComponentProps } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import { floatTab, type SidebarDiffRef } from '../state.ts'
|
||||
import { GitLens } from './GitLens.tsx'
|
||||
import { SessionLens } from './SessionLens.tsx'
|
||||
@@ -63,6 +64,12 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
// up). The cursor is the last delivered seq, so each poll ships only
|
||||
// what the accumulator lacks. ────────────────────────────────────────
|
||||
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
|
||||
// The fold of eventsRef as of the last poll. extractFileOps parses every
|
||||
// accumulated tool/call (up to EVENTS_CAP events); running it once per
|
||||
// poll and REUSING the result across renders (the render used to re-fold
|
||||
// the whole window, twice per tick, and the fresh array defeated the
|
||||
// downstream memo on every poll) keeps the 2.5s tick at one fold.
|
||||
const opsRef = useRef<readonly FileOp[]>([])
|
||||
const seqRef = useRef(0)
|
||||
const pollGen = useRef(0)
|
||||
const [opsError, setOpsError] = useState(false)
|
||||
@@ -77,7 +84,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
|
||||
}
|
||||
if (lastSeq > seqRef.current) seqRef.current = lastSeq
|
||||
opCounts.set(scope.sessionId, extractFileOps(eventsRef.current).length)
|
||||
const folded = extractFileOps(eventsRef.current)
|
||||
opsRef.current = folded
|
||||
opCounts.set(scope.sessionId, folded.length)
|
||||
setOpsError(false)
|
||||
setTick(value => value + 1)
|
||||
} catch {
|
||||
@@ -85,22 +94,25 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
// only while nothing has ever loaded.
|
||||
if (generation === pollGen.current) setOpsError(true)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the poll target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
useEffect(() => {
|
||||
pollGen.current += 1
|
||||
eventsRef.current = []
|
||||
opsRef.current = []
|
||||
seqRef.current = 0
|
||||
setOpsError(false)
|
||||
}, [scope.sessionId])
|
||||
useEffect(() => {
|
||||
void pull()
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void pull() }, 2_500)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, pull])
|
||||
// One pull on every input change — mount, scope change, and visibility
|
||||
// flip all re-pull (a hidden tab still catches up on the flip); only the
|
||||
// poll CADENCE below is gated by visibility.
|
||||
useEffect(() => { void pull() }, [visible, pull])
|
||||
usePolling(visible, pull, { intervalMs: 2_500 })
|
||||
// tick only forces the re-render; the fold reads the ref directly.
|
||||
void tick
|
||||
const ops = extractFileOps(eventsRef.current)
|
||||
const ops = opsRef.current
|
||||
|
||||
/** Persist a meta patch onto the tab (lens choice, pane height). */
|
||||
const patchMeta = (patch: ChangesMeta): void => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { rewriteLocalImageUrls } from '../markdown-images.ts'
|
||||
import { markdownTextProps } from '../markdown-labels.tsx'
|
||||
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
|
||||
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
|
||||
import { createFrameBatcher } from '../frame-batcher.ts'
|
||||
import css from './changes.module.css'
|
||||
import diffCss from '../diff/diff.module.css'
|
||||
|
||||
@@ -246,17 +247,23 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
const paneHeight = dragHeight ?? height
|
||||
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
|
||||
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
|
||||
// Pointer streams fire several times per frame; one setState per event
|
||||
// re-rendered the whole pane at event cadence (see frame-batcher).
|
||||
const dragBatcher = useRef(createFrameBatcher()).current
|
||||
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
|
||||
const onHandleDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
event.preventDefault()
|
||||
dragOrigin.current = { y: event.clientY, h: paneHeight }
|
||||
const onMove = (ev: PointerEvent): void => {
|
||||
if (dragOrigin.current === null) return
|
||||
setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)))
|
||||
const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))
|
||||
dragBatcher.schedule(() => { setDragHeight(next) })
|
||||
}
|
||||
const onUp = (): void => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
dragOrigin.current = null
|
||||
dragBatcher.flushNow()
|
||||
setDragHeight(current => {
|
||||
if (current !== null) onHeightCommit(current)
|
||||
return null
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
|
||||
import { resolveSidebarPath } from '../produced-files.ts'
|
||||
import { relativeTime, t } from '../locales.ts'
|
||||
@@ -64,6 +65,12 @@ function refNames(refs: string): string[] {
|
||||
)]
|
||||
}
|
||||
|
||||
/** One thrown value as display text (every error banner/row here normalizes
|
||||
* through this so non-Error rejections never render as '[object Object]'). */
|
||||
function errorMessage(reason: unknown): string {
|
||||
return reason instanceof Error ? reason.message : String(reason)
|
||||
}
|
||||
|
||||
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
|
||||
interface ConfirmState {
|
||||
title: string
|
||||
@@ -76,6 +83,13 @@ interface ConfirmState {
|
||||
* floods the panel at once (the end of the log is reached by paging). */
|
||||
const LOG_BATCH = 20
|
||||
|
||||
/** Every Nth silent poll re-lists worktrees (and re-runs auto-selection): the
|
||||
* 2s tick only needs the selected checkout's STATUS, and re-listing spawned
|
||||
* a second git process per tick for a list that almost never changes — a
|
||||
* linked checkout the agent creates mid-session is picked up within ~30s
|
||||
* instead of 2s. */
|
||||
const WORKTREE_RECHECK_TICKS = 15
|
||||
|
||||
export interface GitLensProps {
|
||||
scope: SessionScope
|
||||
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
|
||||
@@ -123,6 +137,8 @@ export function GitLens(props: GitLensProps) {
|
||||
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
|
||||
const chosenPathRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree])
|
||||
/** Silent polls since the last worktree re-list (see WORKTREE_RECHECK_TICKS). */
|
||||
const silentTickCount = useRef(0)
|
||||
|
||||
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
|
||||
|
||||
@@ -148,11 +164,14 @@ export function GitLens(props: GitLensProps) {
|
||||
setLogEnded(logResult.length < LOG_BATCH)
|
||||
} catch (reason) {
|
||||
if (options.generation === refreshGeneration.current) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
setError(errorMessage(reason))
|
||||
}
|
||||
} finally {
|
||||
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the git target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, repoRoot])
|
||||
|
||||
const refresh = useCallback(async (silent = false): Promise<void> => {
|
||||
@@ -160,6 +179,15 @@ export function GitLens(props: GitLensProps) {
|
||||
refreshInFlight.current = true
|
||||
let generation = refreshGeneration.current
|
||||
try {
|
||||
// Fast path for the ordinary silent tick: the selected checkout's
|
||||
// STATUS is all that changes between worktree re-lists (see
|
||||
// WORKTREE_RECHECK_TICKS) — one git process instead of two.
|
||||
if (silent && chosenPathRef.current !== undefined && (silentTickCount.current += 1) % WORKTREE_RECHECK_TICKS !== 0) {
|
||||
const statusResult = await api.gitStatus(gitScope, chosenPathRef.current)
|
||||
if (generation === refreshGeneration.current) setStatus(statusResult)
|
||||
return
|
||||
}
|
||||
silentTickCount.current = 0
|
||||
const listed = await api.gitWorktrees(scope)
|
||||
if (generation !== refreshGeneration.current) return
|
||||
setWorktrees(listed)
|
||||
@@ -201,12 +229,15 @@ export function GitLens(props: GitLensProps) {
|
||||
await refreshTarget(target, { loading: !silent, generation })
|
||||
} catch (reason) {
|
||||
if (generation === refreshGeneration.current) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
setError(errorMessage(reason))
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight.current = false
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the refresh target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, refreshTarget])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,6 +245,7 @@ export function GitLens(props: GitLensProps) {
|
||||
refreshInFlight.current = false
|
||||
worktreeChosenByUser.current = false
|
||||
chosenPathRef.current = undefined
|
||||
silentTickCount.current = 0
|
||||
setSelectedWorktree(undefined)
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
@@ -249,11 +281,11 @@ export function GitLens(props: GitLensProps) {
|
||||
const generation = refreshGeneration.current += 1
|
||||
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, refresh])
|
||||
/** The silent poll tick (the status-only fast path between worktree
|
||||
* re-lists, see refresh) — fixed 2s cadence while visible, no initial
|
||||
* burst (mount and scope changes already refresh above). */
|
||||
const pollTick = useCallback((): Promise<void> => refresh(true), [refresh])
|
||||
usePolling(visible, pollTick, { intervalMs: 2_000 })
|
||||
|
||||
/** Append the next history page (lazy: only when the user asks for more). */
|
||||
const loadMoreLog = async (): Promise<void> => {
|
||||
@@ -270,7 +302,7 @@ export function GitLens(props: GitLensProps) {
|
||||
if (next.length < LOG_BATCH) setLogEnded(true)
|
||||
} catch (reason) {
|
||||
if (generation === refreshGeneration.current && target === chosenPathRef.current) {
|
||||
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
setCommitError(`${t('historyLoadError')}: ${errorMessage(reason)}`)
|
||||
}
|
||||
} finally {
|
||||
if (generation === refreshGeneration.current && target === chosenPathRef.current) setLogLoadingMore(false)
|
||||
@@ -336,7 +368,7 @@ export function GitLens(props: GitLensProps) {
|
||||
setCommitMsg('')
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
||||
setCommitError(errorMessage(reason))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -350,7 +382,7 @@ export function GitLens(props: GitLensProps) {
|
||||
await api.gitCheckout(gitScope, branch, selectedWorktree)
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
setCommitError(`${t('checkoutError')}: ${errorMessage(reason)}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -365,7 +397,7 @@ export function GitLens(props: GitLensProps) {
|
||||
await confirmState.onConfirm()
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
||||
setCommitError(errorMessage(reason))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,20 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
|
||||
</button>
|
||||
)
|
||||
|
||||
// Sizing a row's content constructs a Blob (a UTF-8 encode of the whole
|
||||
// edit) — that was per row PER RENDER, re-encoding every field on every
|
||||
// poll tick. ops keeps its identity between unchanged polls (see the
|
||||
// tab's opsRef), so one pass per changed fold covers every render.
|
||||
const opSizes = useMemo(() => {
|
||||
const sizes = new Map<string, string>()
|
||||
for (const op of ops) {
|
||||
if (op.kind !== 'read' && !op.isError) {
|
||||
sizes.set(op.callId, formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size))
|
||||
}
|
||||
}
|
||||
return sizes
|
||||
}, [ops])
|
||||
|
||||
return (
|
||||
<div className={css.session}>
|
||||
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
|
||||
@@ -85,9 +99,9 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
|
||||
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
|
||||
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
|
||||
<span className={css.opMeta}>
|
||||
{op.kind !== 'read' && !op.isError && (
|
||||
{opSizes.get(op.callId) !== undefined && (
|
||||
<span className={css.opSize}>
|
||||
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
|
||||
{opSizes.get(op.callId)}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
|
||||
|
||||
Reference in New Issue
Block a user