mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
fix(ui): make the other Refresh buttons say what they did
The hand test asked for every Refresh button to say what it did; only the
four in Architecture did. Measured in the browser, three more showed
nothing that lasted: Refresh in ADR changed nothing for a single frame,
Refresh projects in the project picker showed "Loading projects..." for
about 150 ms and then the same list, and Refresh in the file impact of
Explore folded the details into "Reading dependencies…" for two seconds
and brought back the same numbers.
The control and its hook move from architecture/ to ui/refresh/, with the
shared words "Up to date at …: no changes since the last load" and
"Refresh failed at …", and these three buttons use it: they read
"Refreshing…" while they run and then name the time and whether anything
changed. Where the view already shows an error in its own words, the
status names only the time. The file impact keeps its details on screen
while a refresh of the same file runs. In the project picker the status
stands on its own line under the buttons.
The System buttons already show it ("Updated" and "Read" with the time),
and the Refresh buttons of Settings, of the projects panel and of the
daemon alerts are not mounted anywhere in the app, so they stay as they
are.
Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
1 parent
0596fb10cf
commit
42e436157c
24 files changed
+304
-40
No files matched your search
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Review of K42 ("other Refresh buttons the same way"): Refresh in ADR read
|
||||
* the decision record again and nothing on screen changed (measured in the
|
||||
* browser: no visible state for a single frame). It now says that it runs and
|
||||
* then when it ran and whether anything changed, as the Architecture
|
||||
* Refresh buttons do.
|
||||
*/
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import AdrWorkspace from './AdrWorkspace';
|
||||
import type { AdrRecord } from '../projects/projects-model';
|
||||
import { clearAdrDraft } from './adr-model';
|
||||
|
||||
let host: HTMLDivElement, root: Root;
|
||||
const project = 'adr-refresh-test';
|
||||
const record = (content: string): AdrRecord => ({ hasAdr: true, content, updatedAt: '2026-09-14T12:00:00Z' });
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
clearAdrDraft(project);
|
||||
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); host.remove(); clearAdrDraft(project); });
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void, reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
const refresh = () => [...host.querySelectorAll('.adr-actions button')][0] as HTMLButtonElement;
|
||||
const status = () => host.querySelector('.adr-actions .atlas-refresh-status')?.textContent;
|
||||
const TIME = '\\d\\d:\\d\\d:\\d\\d';
|
||||
|
||||
it('K42: Refresh in ADR says that it runs, then when it ran and whether the record changed, or that it failed', async () => {
|
||||
let next = deferred<AdrRecord>();
|
||||
const adr = vi.fn().mockResolvedValueOnce(record('# Decisions')).mockImplementation(() => next.promise);
|
||||
await act(async () => root.render(<AdrWorkspace project={project} active source={{ adr, saveAdr: vi.fn() }} />));
|
||||
expect(refresh().textContent).toBe('Refresh');
|
||||
expect(status()).toBe('');
|
||||
|
||||
await act(async () => refresh().click());
|
||||
expect(refresh().textContent).toBe('Refreshing…');
|
||||
expect(refresh().getAttribute('aria-disabled')).toBe('true');
|
||||
await act(async () => next.resolve(record('# Decisions')));
|
||||
expect(refresh().textContent).toBe('Refresh');
|
||||
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
|
||||
|
||||
next = deferred<AdrRecord>();
|
||||
await act(async () => refresh().click());
|
||||
await act(async () => next.resolve(record('# Decisions\n\n## Storage')));
|
||||
expect(status()).toMatch(new RegExp(`^Decisions refreshed at ${TIME}$`));
|
||||
|
||||
next = deferred<AdrRecord>();
|
||||
await act(async () => refresh().click());
|
||||
await act(async () => next.reject(new Error('offline')));
|
||||
// The alert below the toolbar says what failed and offers Try again; the status beside the button only says when.
|
||||
expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`));
|
||||
expect(host.querySelector('.adr-notice[role="alert"]')?.textContent).toContain('Could not load the decision record.');
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { ProjectsSource } from '../projects/ProjectsPanel';
|
||||
import type { AdrRecord } from '../projects/projects-model';
|
||||
import { ADR_CONTENT_LIMIT, adrSize, appendDecision, clearAdrDraft, readAdrDraft, sameAdr, storeAdrDraft } from './adr-model';
|
||||
import { adrStrings as s } from './adr-strings';
|
||||
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import './adr.css';
|
||||
|
||||
export interface AdrWorkspaceProps {
|
||||
@@ -27,13 +28,15 @@ interface EditorState {
|
||||
notice: string;
|
||||
conflict?: AdrRecord;
|
||||
reviewing: boolean;
|
||||
/** How many readings of the record started; the Refresh status speaks about one of them (review of K42). */
|
||||
reads: number;
|
||||
}
|
||||
|
||||
function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JSX.Element {
|
||||
const [state, setState] = useState<EditorState>(() => {
|
||||
const draft = readAdrDraft(project);
|
||||
return { base: draft?.base, content: draft?.content ?? '', editing: !!draft, preview: false,
|
||||
phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false };
|
||||
phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false, reads: 0 };
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [storageAvailable, setStorageAvailable] = useState(true);
|
||||
@@ -60,7 +63,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
|
||||
lastReload.current = reload;
|
||||
if (!active || savingRef.current || (hasLoaded.current && current.phase === 'ready' && current.base && current.content !== current.base.content && !forced)) return;
|
||||
const ticket = ++loadTicket.current;
|
||||
setState(value => ({ ...value, phase: 'loading', error: '' }));
|
||||
setState(value => ({ ...value, phase: 'loading', error: '', reads: value.reads + 1 }));
|
||||
void source.adr(project).then(record => {
|
||||
if (!mounted.current || ticket !== loadTicket.current) return;
|
||||
hasLoaded.current = true;
|
||||
@@ -154,6 +157,14 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
|
||||
editing: !discard, conflict: undefined, reviewing: false, phase: 'ready', error: '', notice: discard ? '' : s.rebased }) : value);
|
||||
const ready = state.phase === 'ready';
|
||||
const documentVisible = !state.editing || state.preview;
|
||||
/*
|
||||
* Review of K42: Refresh read the record again and nothing on screen
|
||||
* changed. Now it says that it runs, then when and whether the record
|
||||
* changed; a failure only gets its time here, the alert below says what
|
||||
* failed and offers Try again.
|
||||
*/
|
||||
const refresh = useRefreshFeedback({ key: String(state.reads), settled: state.phase === 'ready' || state.phase === 'error',
|
||||
value: state.base && { content: state.base.content, updatedAt: state.base.updatedAt, hasAdr: state.base.hasAdr }, error: state.phase === 'error' ? '' : undefined });
|
||||
|
||||
return <section className="adr-workspace" aria-label={s.label} aria-busy={state.phase === 'loading' || saving}>
|
||||
<header className="adr-toolbar">
|
||||
@@ -164,7 +175,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
|
||||
<button type="button" disabled={saving} onClick={cancel}>{s.cancel}</button>
|
||||
<button type="button" className="adr-primary" disabled={!ready || !dirty || !size.valid || saving || !!state.conflict} onClick={() => { void save(); }}>{saving ? s.saving : s.save}</button>
|
||||
</> : <>
|
||||
<button type="button" disabled={state.phase === 'loading'} onClick={() => setReload(value => value + 1)}>{s.refresh}</button>
|
||||
<RefreshControl labels={s.refreshFeedback} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); setReload(value => value + 1); }} />
|
||||
{ready && state.base?.hasAdr && <button type="button" onClick={() => startEditing()}>{s.edit}</button>}
|
||||
{ready && state.base?.hasAdr && <button type="button" className="adr-primary" onClick={() => startEditing(true)}>{s.add}</button>}
|
||||
</>}</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ export const adrStrings = {
|
||||
label: 'Architecture decisions',
|
||||
noProject: 'Select a project to read its architecture decisions.',
|
||||
loading: 'Loading decisions…',
|
||||
refresh: 'Refresh',
|
||||
/** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */
|
||||
refreshFeedback: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Decisions refreshed at ${time}` },
|
||||
retry: 'Try again',
|
||||
edit: 'Edit',
|
||||
add: 'Add decision',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
|
||||
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
|
||||
import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence';
|
||||
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
|
||||
import { RefreshControl, type RefreshFeedback } from './refresh-feedback';
|
||||
import { RefreshControl, type RefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import { operationChoices } from './operation-choices';
|
||||
import './behavior-journey.css';
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('service map interactions', () => {
|
||||
/* Hand test 2026-10-04 (A3): a Refresh that reads everything again says so, and says what it found. */
|
||||
describe('service map refresh feedback', () => {
|
||||
const refresh = () => [...container.querySelectorAll('button')].find(element => /^Refresh(ing…)?$/.test(element.textContent ?? ''))!;
|
||||
const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
|
||||
const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent;
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('A3: reads the declared services again, busy meanwhile, then names the time and whether anything changed', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { loadContainerInventory, loadContainerTopology, type ContainerReading, t
|
||||
import type { ServiceEvidence } from './container-topology';
|
||||
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
|
||||
import { architectureText as text } from './strings';
|
||||
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
|
||||
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import './spatial-architecture.css';
|
||||
import './container-map.css';
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ it('A3: Refresh connections says that it runs and what it found, and keeps the m
|
||||
const input: GraphData = { nodes: [node, route], edges: [], total_nodes: 2 };
|
||||
await act(async () => root.render(<SpatialArchitecture project="sample" graph={input} overview={overview} view="routes" filter="" active onNavigate={vi.fn()} onView={vi.fn()} />));
|
||||
const refresh = () => [...host.querySelectorAll('button')].find(button => /^Refresh(ing)? connections/.test(button.textContent ?? ''))!;
|
||||
const status = () => host.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
|
||||
const status = () => host.querySelector('[role="status"].atlas-refresh-status')?.textContent;
|
||||
expect(refresh().textContent).toBe('Refresh connections');
|
||||
expect(status()).toBe('');
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { CoverageIndex } from '../app/tree-model';
|
||||
import { buildHotspotGraph, collectHotspots, hotspotsForNode, hotspotSignals, hotspotIdentity } from './hotspot-map';
|
||||
import { hotspotAreas } from './hotspot-areas';
|
||||
import { useViewPreferences } from '../settings/view-preferences';
|
||||
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
|
||||
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import './spatial-architecture.css';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -641,7 +641,7 @@ describe('system architecture workspace', () => {
|
||||
/* Hand test 2026-10-04 (A3): "Refresh analysis" and the Behavior "Refresh" read again and said nothing. */
|
||||
describe('system architecture refresh feedback', () => {
|
||||
const refresh = (pattern: RegExp) => [...container.querySelectorAll('button')].find(element => pattern.test(element.textContent ?? ''))!;
|
||||
const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
|
||||
const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent;
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('A3: Refresh analysis is busy while it runs, then names the time and whether the index changed', async () => {
|
||||
@@ -672,7 +672,7 @@ describe('system architecture refresh feedback', () => {
|
||||
vi.setSystemTime(new Date(2026, 9, 4, 18, 1, 0));
|
||||
const loader = vi.fn<SystemArchitectureLoader>().mockResolvedValue(response(overviewFixture()));
|
||||
await render(loader, { view: 'behavior' });
|
||||
const button = () => container.querySelector<HTMLButtonElement>('.behavior-heading .atlas-arch-refresh button')!;
|
||||
const button = () => container.querySelector<HTMLButtonElement>('.behavior-heading .atlas-refresh button')!;
|
||||
expect(button()?.textContent).toBe('Refresh');
|
||||
let finish!: (value: SystemArchitectureResponse) => void;
|
||||
loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
|
||||
@@ -11,7 +11,7 @@ import BehaviorJourney from './BehaviorJourney';
|
||||
import { defaultJourneyStart } from './behavior-journey-model';
|
||||
import type { BehaviorPlace, JourneyPlace, PlaceChange, SystemPlace } from './architecture-history';
|
||||
import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
|
||||
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
|
||||
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import { nameOperations } from './operation-choices';
|
||||
import { RpcIntelligenceClient } from '../provider/rpc-client';
|
||||
import './system-architecture.css';
|
||||
|
||||
@@ -78,12 +78,6 @@
|
||||
.atlas-arch-recent-menu button:hover:not(:disabled) { background: var(--atlas-panel-raised); }
|
||||
.atlas-arch-recent-menu button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--atlas-dim); }
|
||||
.atlas-arch-recent-menu button[aria-current="true"] { opacity: 0.6; }
|
||||
/* Hand test 2026-10-04 (A3): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */
|
||||
.atlas-arch-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; }
|
||||
.atlas-arch-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; }
|
||||
.atlas-arch-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; }
|
||||
.atlas-arch-refresh-status:not(:empty) { margin-left: 8px; }
|
||||
.atlas-arch-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); }
|
||||
.atlas-arch-toolbar { margin-bottom: 24px; gap: 10px; }
|
||||
.atlas-arch-filter { position: relative; flex: 1; max-width: 370px; }
|
||||
.atlas-arch-filter svg { color: var(--atlas-dim); left: 11px; position: absolute; top: 10px; pointer-events: none; }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.behavior-heading h2 { font-size: 25px; font-weight: 500; letter-spacing: -.6px; margin: 7px 0; overflow-wrap: anywhere; }
|
||||
.behavior-heading p { color: var(--atlas-dim); font-size: 12px; margin: 0; }
|
||||
.behavior-journey button { cursor: pointer; }
|
||||
.behavior-heading > button, .behavior-heading > .atlas-arch-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
|
||||
.behavior-heading > button, .behavior-heading > .atlas-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
|
||||
.behavior-journey button:disabled { opacity: .35; cursor: default; }
|
||||
.behavior-journey button:focus-visible { outline: 2px solid var(--arch-label-focus); outline-offset: 3px; }
|
||||
.behavior-requests { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--journey-line); border-radius: 10px; background: var(--journey-tint); }
|
||||
|
||||
@@ -121,14 +121,12 @@ export const architectureText = {
|
||||
},
|
||||
/** The camera button of the maps: back to the whole map. */
|
||||
fitMap: 'Fit map',
|
||||
/** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when, and whether anything changed. */
|
||||
/** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when (shared words in ui/refresh/strings.ts). */
|
||||
refreshFeedback: {
|
||||
routes: { idle: 'Refresh connections', busy: 'Refreshing connections…', done: (time: string) => `Connections refreshed at ${time}` },
|
||||
services: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Service map refreshed at ${time}` },
|
||||
structure: { idle: 'Refresh analysis', busy: 'Refreshing analysis…', done: (time: string) => `Analysis refreshed at ${time}` },
|
||||
behavior: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Journey refreshed at ${time}` },
|
||||
unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`,
|
||||
failed: (time: string, error: string) => `Refresh failed at ${time}: ${error}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Review of K42 ("other Refresh buttons the same way"): Refresh in the file
|
||||
* impact details of Explore collapsed the details into "Reading
|
||||
* dependencies…" for about two seconds and then showed the same numbers
|
||||
* (measured in the browser), with nothing left to say that it had run. The
|
||||
* details now stay on screen, the button says that it runs, and a status
|
||||
* beside it says when it ran and whether anything changed.
|
||||
*/
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import FileImpactSummary from './FileImpactSummary';
|
||||
import type { fetchSelectionImpact, SelectionImpact, SelectionImpactReply } from './selection-impact';
|
||||
|
||||
function impact(direct: number, computed_at: number): SelectionImpact {
|
||||
const node = (id: number, file: string) => ({ id, name: `symbol${id}`, qualified_name: `fixture.symbol${id}`, file_path: file, line: id });
|
||||
return {
|
||||
status: 'ready', project: 'fixture', file_path: 'core.ts', scope: 'file', computed_at, cache_seconds: 30,
|
||||
snapshot: { indexed_at: '2026-09-19T10:00:00Z', generation: 'generation-one', index_revision: 'abc', freshness: 'Current snapshot', coverage_recording: 'complete', coverage: [] },
|
||||
structural: { available: true, basis: 'CALLS and IMPORTS', max_depth: 4, visit_cap: 600, result_cap: 60, seed_count: 1, reachable: direct, direct, test_candidates: 0, truncated: false,
|
||||
findings: Array.from({ length: direct }, (_, at) => ({ ...node(at + 2, `src/caller${at}.ts`), distance: 1, test_candidate: false,
|
||||
path: [{ edge_id: at + 2, type: 'CALLS', from: node(at + 2, `src/caller${at}.ts`), to: node(1, 'core.ts') }] })) },
|
||||
history: { available: true, head: 'a'.repeat(40), shallow: false, worktree_status_known: true, selection_uncommitted: false, truncated: false, commit_cap: 200, window_days: 548,
|
||||
mass_change_threshold: 30, commits_scanned: 20, commits_considered: 15, selection_commits: 3, merges_excluded: 0, mass_changes_excluded: 0, read_errors: 0, cochanges_omitted: 0,
|
||||
commits: [], cochanges: [] },
|
||||
};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement, root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((yes) => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
const refresh = () => container.querySelector<HTMLButtonElement>('.file-impact-summary-details footer .atlas-refresh button');
|
||||
const status = () => container.querySelector('.file-impact-summary-details footer .atlas-refresh-status')?.textContent;
|
||||
const TIME = '\\d\\d:\\d\\d:\\d\\d';
|
||||
|
||||
it('K42: Refresh in the file impact keeps the details, says that it runs, then when it ran and whether anything changed', async () => {
|
||||
let next = deferred<SelectionImpactReply>();
|
||||
const load = vi.fn<typeof fetchSelectionImpact>().mockResolvedValueOnce(impact(2, 1)).mockImplementation(() => next.promise);
|
||||
await act(async () => root.render(<FileImpactSummary project="fixture" target={{ filePath: 'core.ts' }} load={load} onOpen={vi.fn()} />));
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('.file-impact-summary-toggle')!.click());
|
||||
expect(refresh()?.textContent).toBe('Refresh');
|
||||
expect(status()).toBe('');
|
||||
|
||||
await act(async () => refresh()!.click());
|
||||
// While it runs the details stay, and the button says so.
|
||||
expect(container.textContent).toContain('2 affected files');
|
||||
expect(refresh()?.textContent).toBe('Refreshing…');
|
||||
expect(refresh()?.getAttribute('aria-disabled')).toBe('true');
|
||||
// A new computation of the same evidence is no change, although its time stamp is another.
|
||||
await act(async () => next.resolve(impact(2, 99)));
|
||||
expect(refresh()?.textContent).toBe('Refresh');
|
||||
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
|
||||
|
||||
next = deferred<SelectionImpactReply>();
|
||||
await act(async () => refresh()!.click());
|
||||
await act(async () => next.resolve(impact(3, 120)));
|
||||
expect(container.textContent).toContain('3 affected files');
|
||||
expect(status()).toMatch(new RegExp(`^Impact refreshed at ${TIME}$`));
|
||||
});
|
||||
|
||||
it('K42: another file never shows the evidence of the previous one while it loads', async () => {
|
||||
const next = deferred<SelectionImpactReply>();
|
||||
const load = vi.fn<typeof fetchSelectionImpact>().mockResolvedValueOnce(impact(2, 1)).mockImplementation(() => next.promise);
|
||||
await act(async () => root.render(<FileImpactSummary project="fixture" target={{ filePath: 'core.ts' }} load={load} onOpen={vi.fn()} />));
|
||||
expect(container.textContent).toContain('2 affected files');
|
||||
await act(async () => root.render(<FileImpactSummary project="fixture" target={{ filePath: 'other.ts' }} load={load} onOpen={vi.fn()} />));
|
||||
expect(container.textContent).not.toContain('affected files');
|
||||
expect(container.textContent).toContain('Reading dependencies…');
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useId, useMemo, useState } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { evidenceTarget, fetchSelectionImpact } from './selection-impact';
|
||||
import type { SelectionImpact, SelectionImpactFinding, SelectionImpactTarget } from './selection-impact';
|
||||
import { FILE_IMPACT_REFRESH } from './impact-strings';
|
||||
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import './file-impact-summary.css';
|
||||
|
||||
export interface FileImpactSummaryProps {
|
||||
@@ -63,6 +65,8 @@ function countLabel(count: number, noun: string, incomplete = false): string {
|
||||
export default function FileImpactSummary({ project, target, onOpen, expectedGeneration,
|
||||
load = fetchSelectionImpact }: FileImpactSummaryProps): JSX.Element {
|
||||
const [reading, setReading] = useState<Reading>();
|
||||
// The last ready evidence and the selection it belongs to: a refresh of the same selection shows it until the new one is there.
|
||||
const [lastReady, setLastReady] = useState<{ key: string; data: SelectionImpact }>();
|
||||
const [retry, setRetry] = useState({ key: '', count: 0 });
|
||||
const [expandedKey, setExpandedKey] = useState('');
|
||||
const [limits, setLimits] = useState({ key: '', files: 6, history: 4 });
|
||||
@@ -74,12 +78,21 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
|
||||
// Bind every state (including errors) before effects run; a newly selected
|
||||
// file or symbol must never inherit the previous selection's evidence.
|
||||
const current = reading?.key === requestKey ? reading : undefined;
|
||||
const data = current?.status === 'ready' ? current.data : undefined;
|
||||
const waiting = !current || ['loading', 'pending', 'busy'].includes(current.status);
|
||||
/*
|
||||
* Review of K42: Refresh folded the details into "Reading dependencies…"
|
||||
* and then showed the same numbers, with nothing left to say that it had
|
||||
* run. A refresh of the same selection keeps the last evidence on screen,
|
||||
* the button says that it runs, and the status beside it says when and
|
||||
* whether anything changed. Another selection never shows it.
|
||||
*/
|
||||
const kept = waiting && lastReady?.key === selectionKey ? lastReady.data : undefined;
|
||||
const data = current?.status === 'ready' ? current.data : kept;
|
||||
const aggregate = useMemo(() => data ? aggregateFileImpact(data) : undefined, [data]);
|
||||
const expanded = expandedKey === selectionKey;
|
||||
const fileLimit = limits.key === selectionKey ? limits.files : 6;
|
||||
const historyLimit = limits.key === selectionKey ? limits.history : 4;
|
||||
const refresh = (): void => setRetry(value => ({ key: selectionKey,
|
||||
const retryNow = (): void => setRetry(value => ({ key: selectionKey,
|
||||
count: value.key === selectionKey ? value.count + 1 : 1 }));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,6 +111,7 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
|
||||
if (reply.project !== project || reply.file_path !== file)
|
||||
throw new Error('The response belongs to another selection.');
|
||||
setReading({ key: requestKey, status: 'ready', data: reply });
|
||||
setLastReady({ key: selectionKey, data: reply });
|
||||
} else if (reply.status === 'failed') {
|
||||
setReading({ key: requestKey, status: 'failed', error: reply.error });
|
||||
} else if (attempts < 80) {
|
||||
@@ -111,7 +125,12 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
|
||||
};
|
||||
void read();
|
||||
return () => { controller.abort(); if (timer !== undefined) clearTimeout(timer); };
|
||||
}, [project, file, qualifiedName, id, load, requestKey, retryCount]);
|
||||
}, [project, file, qualifiedName, id, load, requestKey, retryCount, selectionKey]);
|
||||
// What counts as a change: the evidence shown, not the time stamp of its computation.
|
||||
const refresh = useRefreshFeedback({ key: requestKey, settled: !waiting, error: current?.status === 'failed' ? '' : undefined,
|
||||
value: data && aggregate ? { generation: data.snapshot.generation, revision: data.snapshot.index_revision, direct: data.structural.direct,
|
||||
files: aggregate.files.map(row => [row.filePath, row.distance, row.symbols, row.testCandidate]), cochanges: aggregate.cochanges.map(row => [row.file_path, row.shared_commits]),
|
||||
commits: data.history.selection_commits } : undefined });
|
||||
|
||||
const notes: string[] = [];
|
||||
if (data && aggregate) {
|
||||
@@ -130,10 +149,10 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
|
||||
<div className="file-impact-summary-row">
|
||||
<span className="file-impact-summary-label" title={file}>{scope} impact</span>
|
||||
{!file && <span className="file-impact-summary-muted">Select a file to see its dependents.</span>}
|
||||
{file && (!current || ['loading', 'pending', 'busy'].includes(current.status))
|
||||
{file && waiting && !kept
|
||||
&& <span role="status" className="file-impact-summary-muted">{current?.status === 'busy' ? 'Waiting for local analysis…' : 'Reading dependencies…'}</span>}
|
||||
{current?.status === 'failed' && <><span role="alert" className="file-impact-summary-warning"
|
||||
title={current.error}>Impact unavailable</span><button type="button" onClick={refresh}>Retry</button></>}
|
||||
title={current.error}>Impact unavailable</span><button type="button" onClick={retryNow}>Retry</button></>}
|
||||
{data && aggregate && <>
|
||||
<div className="file-impact-summary-metrics">
|
||||
{data.structural.available ? <>
|
||||
@@ -174,7 +193,7 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
|
||||
</section>
|
||||
</div>
|
||||
<footer><span>Indexed calls/imports · up to {data.structural.max_depth} hops. Test files are candidates; Git co-changes are associations.</span>
|
||||
<button type="button" onClick={refresh}>Refresh</button></footer>
|
||||
<RefreshControl labels={FILE_IMPACT_REFRESH} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); retryNow(); }} /></footer>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -282,3 +282,8 @@ export function impactUntestedRule(count: number): string {
|
||||
export function impactSymbolRule(name: string, level: 'low' | 'medium' | 'high', reasons: string[]): string {
|
||||
return `${name} is ${RISK_LABELS[level]}: ${reasons.join('; ')}.`;
|
||||
}
|
||||
|
||||
// Review zu K42: der Knopf Refresh der Dateiwirkung in Explore sagt, dass er laeuft, dann wann er lief ---
|
||||
// (die gemeinsamen Saetze "Up to date at …" und "Refresh failed at …" stehen in ui/refresh/strings.ts).
|
||||
|
||||
export const FILE_IMPACT_REFRESH = { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Impact refreshed at ${time}` };
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Review of K42 ("other Refresh buttons the same way"): "Refresh projects"
|
||||
* in the project picker read the list again, showed "Loading projects..."
|
||||
* for about 150 ms and then the same list (measured in the browser). It now
|
||||
* says that it runs and then when it ran and whether the list changed.
|
||||
*/
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import ProjectSwitcher from './ProjectSwitcher';
|
||||
import type { ProjectEntry } from '../provider/rpc-schemas';
|
||||
|
||||
let container: HTMLDivElement, root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void, reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
const refresh = () => container.querySelector<HTMLButtonElement>('.atlas-project-picker footer .atlas-refresh button')!;
|
||||
const status = () => container.querySelector('.atlas-project-picker footer .atlas-refresh-status')?.textContent;
|
||||
const TIME = '\\d\\d:\\d\\d:\\d\\d';
|
||||
const alpha: ProjectEntry = { name: 'alpha', root_path: '/repos/alpha' };
|
||||
|
||||
it('K42: Refresh projects says that it runs, then when it ran and whether the list changed, or that it failed', async () => {
|
||||
let next = deferred<readonly ProjectEntry[]>();
|
||||
const list = vi.fn().mockResolvedValueOnce([alpha]).mockImplementation(() => next.promise);
|
||||
await act(async () => root.render(<ProjectSwitcher currentProject="alpha" listProjects={list} onSelectProject={vi.fn()} onAddProject={vi.fn()} />));
|
||||
await act(async () => container.querySelector('summary')!.click());
|
||||
expect(refresh().textContent).toBe('Refresh projects');
|
||||
expect(status()).toBe('');
|
||||
|
||||
await act(async () => refresh().click());
|
||||
expect(refresh().textContent).toBe('Refreshing projects…');
|
||||
expect(refresh().getAttribute('aria-disabled')).toBe('true');
|
||||
await act(async () => next.resolve([alpha]));
|
||||
expect(refresh().textContent).toBe('Refresh projects');
|
||||
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
|
||||
|
||||
next = deferred<readonly ProjectEntry[]>();
|
||||
await act(async () => refresh().click());
|
||||
await act(async () => next.resolve([alpha, { name: 'beta', root_path: '/repos/beta' }]));
|
||||
expect(status()).toMatch(new RegExp(`^Projects refreshed at ${TIME}$`));
|
||||
expect(container.querySelectorAll('.atlas-project-results li')).toHaveLength(2);
|
||||
|
||||
next = deferred<readonly ProjectEntry[]>();
|
||||
await act(async () => refresh().click());
|
||||
await act(async () => next.reject(new Error('offline')));
|
||||
// The list area says what failed and offers Try again; the status beside the button says when.
|
||||
expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`));
|
||||
expect(container.querySelector('.atlas-project-results')?.textContent).toContain('Could not load projects.');
|
||||
|
||||
// Reopening the picker reads the list again by itself: that is no press of Refresh, and the status goes.
|
||||
next = deferred<readonly ProjectEntry[]>();
|
||||
await act(async () => container.querySelector('summary')!.click());
|
||||
await act(async () => container.querySelector('summary')!.click());
|
||||
await act(async () => next.resolve([alpha]));
|
||||
expect(status()).toBe('');
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { JSX } from 'react';
|
||||
import type { ProjectEntry } from '../provider/rpc-schemas';
|
||||
import { projectSwitcherStrings as text } from './project-switcher-strings';
|
||||
import { projectPickerPlacement } from './project-picker-placement';
|
||||
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import './project-switcher.css';
|
||||
|
||||
interface ProjectSwitcherProps {
|
||||
@@ -18,6 +19,8 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
|
||||
const [entries, setEntries] = useState<readonly ProjectEntry[]>([]);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [revision, setRevision] = useState(0);
|
||||
// Every reading of the list counts, also the one an opening starts; the Refresh status speaks about one of them (review of K42).
|
||||
const [reads, setReads] = useState(0);
|
||||
const [placement, setPlacement] = useState({ top: 64, left: 12, width: 370, maxHeight: 520 });
|
||||
const disclosure = useRef<HTMLDetailsElement>(null);
|
||||
const trigger = useRef<HTMLElement>(null);
|
||||
@@ -49,6 +52,7 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
|
||||
if (!open) return;
|
||||
let current = true;
|
||||
setStatus('loading');
|
||||
setReads(value => value + 1);
|
||||
void Promise.resolve().then(props.listProjects).then(
|
||||
(projects) => {
|
||||
if (!current) return;
|
||||
@@ -69,6 +73,15 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
|
||||
return () => document.removeEventListener('pointerdown', outside);
|
||||
}, [open]);
|
||||
|
||||
/*
|
||||
* Review of K42: "Refresh projects" showed "Loading projects..." for about
|
||||
* 150 ms and then the same list. Now it says that it runs, then when and
|
||||
* whether the list changed; a failure only gets its time, the list area
|
||||
* says what failed and offers Try again.
|
||||
*/
|
||||
const refresh = useRefreshFeedback({ key: String(reads), settled: status !== 'loading',
|
||||
value: entries.map(entry => [entry.name, entry.root_path]), error: status === 'error' ? '' : undefined });
|
||||
|
||||
const projects = Array.from(new Map(entries.map(entry => [entry.name, entry])).values())
|
||||
.filter(entry => entry.name.length > 0)
|
||||
.sort((left, right) => Number(right.name === props.currentProject) - Number(left.name === props.currentProject)
|
||||
@@ -120,7 +133,7 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
|
||||
</button>}
|
||||
<footer>
|
||||
<button type="button" onClick={() => { close(true); props.onAddProject(); }}>{text.add}</button>
|
||||
<button type="button" disabled={status === 'loading'} onClick={() => setRevision(value => value + 1)}>{text.refresh}</button>
|
||||
<RefreshControl labels={text.refreshFeedback} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); setRevision(value => value + 1); }} />
|
||||
</footer>
|
||||
</div>}
|
||||
</details>;
|
||||
|
||||
@@ -10,7 +10,8 @@ export const projectSwitcherStrings = {
|
||||
noMatches: 'No matching projects.',
|
||||
current: 'Current',
|
||||
retry: 'Try again',
|
||||
refresh: 'Refresh projects',
|
||||
/** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */
|
||||
refreshFeedback: { idle: 'Refresh projects', busy: 'Refreshing projects…', done: (time: string) => `Projects refreshed at ${time}` },
|
||||
add: 'Add project index',
|
||||
indexActivity: (status: 'indexing' | 'done' | 'error') => status === 'indexing' ? 'Indexing…' : status === 'done' ? 'Index ready' : 'Index needs attention',
|
||||
};
|
||||
@@ -18,8 +18,13 @@
|
||||
.atlas-project-result-name { display: flex; justify-content: space-between; gap: 8px; overflow-wrap: anywhere; }
|
||||
.atlas-project-result-name small { flex-shrink: 0; align-self: flex-start; color: var(--atlas-phosphor); font-size: 10px; }
|
||||
.atlas-project-result-path { display: block; margin-top: 5px; color: var(--atlas-dim); font: 10px var(--atlas-mono); overflow-wrap: anywhere; }
|
||||
.atlas-project-picker footer { display: flex; justify-content: space-between; flex-shrink: 0; gap: 8px; padding-top: 10px; border-top: 1px solid var(--atlas-line); }
|
||||
.atlas-project-picker footer { display: flex; flex-wrap: wrap; justify-content: space-between; flex-shrink: 0; gap: 8px; padding-top: 10px; border-top: 1px solid var(--atlas-line); }
|
||||
.atlas-project-picker footer button { padding: 6px 8px; color: var(--atlas-cyan); background: transparent; border: 1px solid var(--atlas-line); }
|
||||
/* Review of K42: what Refresh projects found stands on a line of its own under the buttons; beside them it ran past the edge of the picker. */
|
||||
.atlas-project-picker footer .atlas-refresh { display: contents; }
|
||||
.atlas-project-picker footer .atlas-refresh-status { flex-basis: 100%; margin: 0; text-align: right; }
|
||||
/* Empty, it leaves the row but not the tree: the live region has to be there before it speaks. */
|
||||
.atlas-project-picker footer .atlas-refresh-status:empty { position: absolute; }
|
||||
.atlas-project-picker button:disabled { opacity: .5; cursor: default; }
|
||||
.atlas-project-index-activity { display: flex; justify-content: space-between; gap: 10px; padding: 10px; color: var(--atlas-fg); background: var(--atlas-select); border: 1px solid var(--atlas-line); text-align: left; overflow-wrap: anywhere; }
|
||||
.atlas-project-index-activity span:first-child { color: var(--atlas-cyan); }
|
||||
|
||||
File renamed without changes.
+10
-9
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState, type JSX } from 'react';
|
||||
import { architectureText } from './strings';
|
||||
|
||||
const text = architectureText.refreshFeedback;
|
||||
import { refreshText as text } from './strings';
|
||||
import './refresh.css';
|
||||
|
||||
/**
|
||||
* What a Refresh button of Architecture says it did (hand test 2026-10-04, A3:
|
||||
* "Refresh connections" fetched everything again and nothing on screen
|
||||
* changed). While the reading runs the button reads "Refreshing …"; then a
|
||||
* status beside it names the time and whether anything changed, or the error.
|
||||
* What a Refresh button says it did (hand test 2026-10-04, A3: "Refresh
|
||||
* connections" fetched everything again and nothing on screen changed). While
|
||||
* the reading runs the button reads "Refreshing …"; then a status beside it
|
||||
* names the time and whether anything changed, or the error. Made for the
|
||||
* four Refresh buttons of Architecture and shared since the review of K42 with
|
||||
* those of ADR, the project picker and the file impact in Explore.
|
||||
*/
|
||||
export type RefreshFeedback =
|
||||
| { phase: 'running' }
|
||||
@@ -74,9 +75,9 @@ export function RefreshControl({ labels, feedback, onRefresh, className }: {
|
||||
feedback?: RefreshFeedback; onRefresh: () => void; className?: string;
|
||||
}): JSX.Element {
|
||||
const running = feedback?.phase === 'running';
|
||||
return <span className="atlas-arch-refresh" data-phase={feedback?.phase}>
|
||||
return <span className="atlas-refresh" data-phase={feedback?.phase}>
|
||||
<button type="button" className={className} aria-disabled={running || undefined} aria-busy={running || undefined}
|
||||
onClick={() => { if (!running) onRefresh(); }}>{running ? labels.busy : labels.idle}</button>
|
||||
<span className="atlas-arch-refresh-status" role="status" data-phase={feedback?.phase}>{refreshStatus(feedback, labels.done)}</span>
|
||||
<span className="atlas-refresh-status" role="status" data-phase={feedback?.phase}>{refreshStatus(feedback, labels.done)}</span>
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Hand test 2026-10-04 (A3, K42): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */
|
||||
.atlas-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; }
|
||||
.atlas-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; }
|
||||
.atlas-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; }
|
||||
.atlas-refresh-status:not(:empty) { margin-left: 8px; }
|
||||
.atlas-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); }
|
||||
@@ -0,0 +1,6 @@
|
||||
/** What every Refresh button of the app says after it ran (hand test 2026-10-04, A3, and its review, K42). */
|
||||
export const refreshText = {
|
||||
unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`,
|
||||
/** Without a reason when the view already shows the error in its own words beside the button. */
|
||||
failed: (time: string, error: string) => (error ? `Refresh failed at ${time}: ${error}` : `Refresh failed at ${time}`),
|
||||
};
|
||||
Reference in new issue
Block a user