singularity-forge/vscode-extension/src/session-tree.ts
2026-05-05 14:46:18 +02:00

163 lines
4.2 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
import type { SfClient } from "./sf-client.js";
export interface SessionItem {
label: string;
sessionFile: string;
timestamp: Date;
sessionId: string;
isCurrent: boolean;
}
/**
* Tree view provider that lists SF session files from the same directory
* as the currently active session.
*/
export class SfSessionTreeProvider
implements vscode.TreeDataProvider<SessionItem>, vscode.Disposable
{
public static readonly viewId = "sf-sessions";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private sessions: SessionItem[] = [];
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: SfClient) {
this.disposables.push(
this._onDidChangeTreeData,
client.onConnectionChange(() => this.refresh()),
);
}
async refresh(): Promise<void> {
this.sessions = await this.loadSessions();
this._onDidChangeTreeData.fire();
}
private async loadSessions(): Promise<SessionItem[]> {
if (!this.client.isConnected) {
return [];
}
try {
const state = await this.client.getState();
this.currentSessionFile = state.sessionFile;
if (!state.sessionFile) {
return [];
}
const sessionDir = path.dirname(state.sessionFile);
const files = fs
.readdirSync(sessionDir)
.filter((f) => f.endsWith(".jsonl"))
.sort()
.reverse(); // newest first
const items: SessionItem[] = [];
for (const file of files) {
const sessionFile = path.join(sessionDir, file);
// Try two filename formats:
// 1. ISO timestamp: 2026-03-23T17-49-05-784Z_<sessionId>.jsonl
// 2. Unix timestamp: <unixTimestampMs>_<sessionId>.jsonl
const isoMatch = file.match(
/^(\d{4}-\d{2}-\d{2}T[\d-]+Z)_(.+)\.jsonl$/,
);
const unixMatch = file.match(/^(\d{10,})_(.+)\.jsonl$/);
let timestamp: Date;
let sessionId: string;
if (isoMatch) {
// Convert ISO-like format (dashes instead of colons) back to parseable ISO
const isoStr = isoMatch[1].replace(
/(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d+)Z/,
"$1:$2:$3.$4Z",
);
timestamp = new Date(isoStr);
sessionId = isoMatch[2];
} else if (unixMatch) {
timestamp = new Date(parseInt(unixMatch[1], 10));
sessionId = unixMatch[2];
} else {
continue;
}
if (Number.isNaN(timestamp.getTime())) continue;
items.push({
label: formatDate(timestamp),
sessionFile,
timestamp,
sessionId,
isCurrent: sessionFile === state.sessionFile,
});
}
return items;
} catch {
return [];
}
}
getTreeItem(element: SessionItem): vscode.TreeItem {
const item = new vscode.TreeItem(
element.label,
vscode.TreeItemCollapsibleState.None,
);
item.description = element.sessionId.slice(0, 8);
item.tooltip = new vscode.MarkdownString(
`**${element.label}**\n\nID: \`${element.sessionId}\`\n\nFile: \`${element.sessionFile}\``,
);
item.iconPath = new vscode.ThemeIcon(
element.isCurrent ? "comment-discussion" : "history",
element.isCurrent
? new vscode.ThemeColor("terminal.ansiGreen")
: undefined,
);
if (!element.isCurrent) {
item.command = {
command: "sf.switchSession",
title: "Switch to Session",
arguments: [element.sessionFile],
};
}
item.contextValue = element.isCurrent ? "currentSession" : "session";
return item;
}
getChildren(): SessionItem[] {
return this.sessions;
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
}
function formatDate(d: Date): string {
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffDays === 0) {
return `Today ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays === 1) {
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays < 7) {
return d.toLocaleDateString([], {
weekday: "short",
hour: "2-digit",
minute: "2-digit",
});
}
return d.toLocaleDateString([], {
month: "short",
day: "numeric",
year: "numeric",
});
}