Skip to content
Open
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
54 changes: 48 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ function idFromUri(uri: vscode.Uri): string {
return uri.path.replace(/^\/+/, '').replace(/\.log$/, '');
}

function uriForExecution(id: string): vscode.Uri {
return vscode.Uri.parse(`${OUTPUT_VIEWER_SCHEME}:/${id}.log`);
}

/**
* Backs the "click to open output" viewer. Uses FileSystemProvider registered
* with { isReadonly: true } rather than TextDocumentContentProvider: the
Expand All @@ -166,8 +170,42 @@ class OutputFileSystemProvider implements vscode.FileSystemProvider {
private readonly _onDidChangeFile = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
readonly onDidChangeFile = this._onDidChangeFile.event;

/**
* Ids this provider has actually served content for, which is the set that
* can have a stale document open against it. Bounded: an id is dropped as
* soon as it leaves the store, and while it is in the store it is bounded by
* truthlog.maxEntries.
*/
private readonly served = new Set<string>();

constructor(private readonly store: ExecutionStore) {}

/**
* A recorded execution never changes once stored, so the only content change
* that can happen is an entry leaving history, via Clear History or the
* maxEntries trim. Without firing here, VS Code has no reason to call
* readFile again and an open tab keeps showing an execution that is gone.
*
* Fires Changed rather than Deleted on purpose: readFile already answers for
* a missing id with "no longer in history", so the tab re-reads and says so,
* which is more useful than having the editor yanked out from under someone
* who was reading it. It also keeps this honest in the way the rest of the
* codebase is, by explaining the absence instead of just presenting stale
* content as current.
*/
refreshRemoved(): void {
const events: vscode.FileChangeEvent[] = [];
for (const id of this.served) {
if (this.store.getById(id) === undefined) {
events.push({ type: vscode.FileChangeType.Changed, uri: uriForExecution(id) });
this.served.delete(id);
}
}
if (events.length > 0) {
this._onDidChangeFile.fire(events);
}
}

watch(): vscode.Disposable {
return new vscode.Disposable(() => undefined);
}
Expand All @@ -193,6 +231,7 @@ class OutputFileSystemProvider implements vscode.FileSystemProvider {

readFile(uri: vscode.Uri): Uint8Array {
const id = idFromUri(uri);
this.served.add(id);
const execution = this.store.getById(id);
const text = execution
? renderOutputDocument(execution)
Expand Down Expand Up @@ -270,21 +309,24 @@ export function activate(context: vscode.ExtensionContext): TruthLogTestApi {
context.subscriptions.push(treeView);

// Read-only virtual filesystem backing the "click to open output" view.
const outputProvider = new OutputFileSystemProvider(store);
context.subscriptions.push(
vscode.workspace.registerFileSystemProvider(
OUTPUT_VIEWER_SCHEME,
new OutputFileSystemProvider(store),
{ isReadonly: true }
)
vscode.workspace.registerFileSystemProvider(OUTPUT_VIEWER_SCHEME, outputProvider, {
isReadonly: true,
})
);
// Clearing or trimming history has to reach any tab already open on an entry
// that just disappeared, or it keeps rendering content that is no longer
// there to back it.
context.subscriptions.push(store.onDidChange(() => outputProvider.refreshRemoved()));

context.subscriptions.push(
vscode.commands.registerCommand('truthlog.openOutput', async (id: unknown) => {
if (typeof id !== 'string') {
log(`WARNING truthlog.openOutput called without a valid id: ${JSON.stringify(id)}`);
return;
}
const uri = vscode.Uri.parse(`${OUTPUT_VIEWER_SCHEME}:/${id}.log`);
const uri = uriForExecution(id);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc, { preview: true });
})
Expand Down
71 changes: 71 additions & 0 deletions src/test/suite/outputTabRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import type { TruthLogTestApi } from '../../extension';
import type { Execution } from '../../types';

function syntheticExecution(marker: string): Execution {
const startedAt = Date.now();
return {
id: `output-refresh-${startedAt}`,
command: `echo ${marker}`,
cwd: undefined,
exitCode: 0,
startedAt,
endedAt: startedAt + 10,
output: `${marker}\n`,
truncated: false,
};
}

async function waitForText(
doc: vscode.TextDocument,
needle: string,
timeoutMs: number
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (doc.getText().includes(needle)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
return doc.getText().includes(needle);
}

describe('open output tab after the entry leaves history', function () {
this.timeout(30000);

it('re-reads and says the execution is gone instead of showing stale content', async () => {
const ext = vscode.extensions.getExtension<TruthLogTestApi>('abhijeet.truthlog');
assert.ok(ext, 'extension not found');
const api = ext.isActive ? ext.exports : await ext.activate();

const marker = 'truthlog-stale-tab-marker';
const execution = syntheticExecution(marker);
api.store.add(execution);

const uri = vscode.Uri.parse(`truthlog-output:/${execution.id}.log`);
const doc = await vscode.workspace.openTextDocument(uri);

assert.ok(
doc.getText().includes(marker),
`expected the opened document to show the execution output, got: ${doc.getText().slice(0, 200)}`
);

api.store.clear();

const refreshed = await waitForText(doc, 'no longer in history', 10000);
console.log('RAW document text after clear:', JSON.stringify(doc.getText().slice(0, 200)));

assert.ok(
refreshed,
`expected the open tab to re-read after the entry left history, still showing: ${doc
.getText()
.slice(0, 200)}`
);
assert.ok(
!doc.getText().includes(marker),
'the cleared execution output must not still be on screen'
);
});
});
Loading