blob: a7f41fa3d695b06707fc176c5e51098df879cdff [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {z} from 'zod';
import {assertTrue} from '../../base/assert';
import {Card, CardStack} from '../../widgets/card';
import {Button} from '../../widgets/button';
import {Icons} from '../../base/semantic_icons';
import type {DataExplorerState} from './data_explorer';
import {serializeState} from './json_handler';
import {getAllNodes} from './query_builder/graph_utils';
const RECENT_GRAPHS_KEY = 'recentExploreGraphs';
// Schema for a single recent graph entry
const RECENT_GRAPH_ENTRY_SCHEMA = z.object({
name: z.string(),
json: z.string(),
timestamp: z.number(),
nodeCount: z.number().optional(),
labelCount: z.number().optional(),
starred: z.boolean().default(false),
});
export type RecentGraphEntry = z.infer<typeof RECENT_GRAPH_ENTRY_SCHEMA>;
const RECENT_GRAPHS_SCHEMA = z.array(RECENT_GRAPH_ENTRY_SCHEMA);
export type RecentGraphs = z.infer<typeof RECENT_GRAPHS_SCHEMA>;
/**
* Storage class for recent data explorer graphs.
* Stores serialized graph states in localStorage.
*
* The data array uses index 0 as a "working slot" for the current graph being
* edited. When the user switches to a different graph (via import, example, or
* clear), the current graph is "finalized" - it gets a proper name and becomes
* a historical entry at index 1+. A new empty placeholder is then inserted at
* index 0 for the next graph.
*
* Historical graphs (index 1+) are displayed in the Recent Graphs section.
* The working slot (index 0) is never displayed to the user.
*/
export class RecentGraphsStorage {
private _data: RecentGraphs;
maxItems = 10;
constructor() {
this._data = this.load();
}
/**
* Returns the recent graphs data. Historical graphs start at index 1.
* Index 0 is the current working graph (not displayed in UI).
*/
get data(): RecentGraphs {
return this._data;
}
/**
* Sets the data directly. Used for testing.
*/
set data(value: RecentGraphs) {
this._data = value;
}
/**
* Saves the current graph state. This updates the working slot (index 0),
* or creates it if the list is empty.
* Called on every state change to persist the current work.
*/
saveCurrentState(state: DataExplorerState): void {
// Don't save empty graphs
if (state.rootNodes.length === 0) {
return;
}
const json = serializeState(state);
const timestamp = Date.now();
const nodeCount = getAllNodes(state.rootNodes).length;
const labelCount = state.labels.length;
if (this._data.length === 0) {
// No entries yet - create the working slot
this._data.unshift({
name: this.generateName(),
json,
timestamp,
nodeCount,
labelCount,
starred: false,
});
} else {
// Update the working slot in place
this._data[0].json = json;
this._data[0].timestamp = timestamp;
this._data[0].nodeCount = nodeCount;
this._data[0].labelCount = labelCount;
}
this.save();
}
/**
* Finalizes the current graph and prepares for a new one.
* Called when the user switches to a different graph (New graph, tutorial, etc.).
* The working slot (index 0) becomes a historical entry (moved to index 1+).
*/
finalizeCurrentGraph(): void {
const hasWorkingGraph =
this._data.length > 0 && (this._data[0].nodeCount ?? 0) > 0;
if (!hasWorkingGraph) {
return;
}
// Give the current graph a proper name based on when it was finalized
this._data[0].name = this.generateName();
// Count unstarred items (excluding working slot which will become historical)
let lastUnstarredIndex = -1;
let unstarredCount = 0;
for (let i = 0; i < this._data.length; i++) {
if (!this._data[i].starred) {
unstarredCount++;
lastUnstarredIndex = i;
}
}
// If we're at max unstarred capacity, remove the oldest unstarred
// (but not the working slot at index 0)
if (unstarredCount >= this.maxItems && lastUnstarredIndex > 0) {
this._data.splice(lastUnstarredIndex, 1);
}
// Insert a new empty working slot at index 0
// This pushes the finalized graph to index 1
this._data.unshift({
name: 'Current',
json: '',
timestamp: Date.now(),
nodeCount: 0,
starred: false,
});
this.save();
}
/**
* Gets the most recent valid graph's JSON for restoring state on page load.
* Skips empty working slots and returns the first graph with actual content.
*/
getCurrentJson(): string | undefined {
for (const entry of this._data) {
if (entry.json && (entry.nodeCount ?? 0) > 0) {
return entry.json;
}
}
return undefined;
}
/**
* Sets the starred status of a graph.
*/
setStarred(index: number, starred: boolean): void {
assertTrue(index >= 0 && index < this._data.length);
this._data[index].starred = starred;
this.save();
}
/**
* Renames a graph.
*/
rename(index: number, newName: string): void {
assertTrue(index >= 0 && index < this._data.length);
this._data[index].name = newName.trim() || this._data[index].name;
this.save();
}
/**
* Generates a unique name for a new graph based on timestamp.
*/
generateName(): string {
const now = new Date();
const dateStr = now.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
const timeStr = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `Graph ${dateStr} ${timeStr}`;
}
/**
* Gets a graph's JSON by index.
*/
getJson(index: number): string | undefined {
if (index >= 0 && index < this._data.length) {
return this._data[index].json;
}
return undefined;
}
/**
* Removes a graph from the list by index.
*/
remove(index: number): void {
assertTrue(index >= 0 && index < this._data.length);
this._data.splice(index, 1);
this.save();
}
/**
* Clears all stored data. Used when stored data is corrupted.
*/
clear(): void {
this._data = [];
this.save();
}
private load(): RecentGraphs {
const value = window.localStorage.getItem(RECENT_GRAPHS_KEY);
if (value === null) {
return [];
}
try {
const res = RECENT_GRAPHS_SCHEMA.safeParse(JSON.parse(value));
return res.success ? res.data : [];
} catch {
// Invalid JSON in localStorage, return empty array
return [];
}
}
private save(): void {
try {
window.localStorage.setItem(
RECENT_GRAPHS_KEY,
JSON.stringify(this._data),
);
} catch (e) {
// Handle localStorage quota exceeded or other storage errors.
// Log the error but don't crash - recent graphs is a nice-to-have feature.
console.warn('Failed to save recent graphs to localStorage:', e);
}
}
}
// Singleton instance
export const recentGraphsStorage = new RecentGraphsStorage();
export interface RecentGraphsSectionAttrs {
readonly onLoadGraph: (json: string) => void;
}
interface RecentGraphCardAttrs {
readonly entry: RecentGraphEntry;
readonly index: number;
readonly onLoadGraph: (json: string) => void;
}
/**
* Component that renders a single recent graph card.
*/
class RecentGraphCard implements m.ClassComponent<RecentGraphCardAttrs> {
private isEditing = false;
private editName = '';
private startEditing(entry: RecentGraphEntry, e: Event): void {
e.stopPropagation();
this.isEditing = true;
this.editName = entry.name;
}
private finishEditing(index: number): void {
if (this.editName.trim()) {
recentGraphsStorage.rename(index, this.editName);
}
this.isEditing = false;
}
private cancelEditing(): void {
this.isEditing = false;
}
view({attrs}: m.CVnode<RecentGraphCardAttrs>): m.Children {
const {entry, index} = attrs;
if (this.isEditing) {
return m(
Card,
{
className: 'pf-recent-graph-card',
onclick: (e: Event) => e.stopPropagation(),
},
m(
'div',
m('input', {
type: 'text',
value: this.editName,
oninput: (e: Event) => {
this.editName = (e.target as HTMLInputElement).value;
},
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
this.finishEditing(index);
} else if (e.key === 'Escape') {
e.preventDefault();
this.cancelEditing();
}
},
oncreate: (vnode: m.VnodeDOM<unknown>) => {
(vnode.dom as HTMLInputElement).focus();
(vnode.dom as HTMLInputElement).select();
},
}),
m('p', `${entry.nodeCount ?? '?'} nodes`),
),
m(
'.pf-recent-graph-card__actions',
m(Button, {
icon: 'check',
title: 'Save',
onclick: (e: Event) => {
e.stopPropagation();
this.finishEditing(index);
},
}),
m(Button, {
icon: 'close',
title: 'Cancel',
onclick: (e: Event) => {
e.stopPropagation();
this.cancelEditing();
},
}),
),
);
}
const nodeCount = entry.nodeCount ?? 0;
const labelCount = entry.labelCount ?? 0;
return m(
Card,
{
interactive: true,
className: 'pf-recent-graph-card',
onclick: () => {
const json = recentGraphsStorage.getJson(index);
if (json !== undefined) {
attrs.onLoadGraph(json);
}
},
},
m(Button, {
icon: Icons.Star,
iconFilled: entry.starred,
title: entry.starred ? 'Unstar' : 'Star',
onclick: (e: Event) => {
e.stopPropagation();
recentGraphsStorage.setStarred(index, !entry.starred);
},
}),
m(
'div',
m('h3', entry.name),
m('p', `${nodeCount} nodes, ${labelCount} labels`),
),
m(
'.pf-recent-graph-card__actions',
m(Button, {
icon: 'edit',
title: 'Rename',
onclick: (e: Event) => this.startEditing(entry, e),
}),
m(Button, {
icon: Icons.Delete,
title: 'Remove from recent',
onclick: (e: Event) => {
e.stopPropagation();
recentGraphsStorage.remove(index);
},
}),
),
);
}
}
/**
* Component that renders the "Recent graphs" section in the sidebar.
* Shows historical graphs (not the current one at index 0).
* Starred graphs appear first, followed by unstarred graphs.
* Always shows the section header, even when empty.
*/
export class RecentGraphsSection
implements m.ClassComponent<RecentGraphsSectionAttrs>
{
view({attrs}: m.CVnode<RecentGraphsSectionAttrs>): m.Children {
const recentGraphs = recentGraphsStorage.data;
// Separate starred and unstarred graphs while preserving indices
// Start from index 1 to skip the current graph (index 0)
const starred: Array<{entry: RecentGraphEntry; index: number}> = [];
const unstarred: Array<{entry: RecentGraphEntry; index: number}> = [];
for (let i = 1; i < recentGraphs.length; i++) {
const entry = recentGraphs[i];
// Skip empty placeholders (working slots with no content)
if ((entry.nodeCount ?? 0) === 0) continue;
if (entry.starred) {
starred.push({entry, index: i});
} else {
unstarred.push({entry, index: i});
}
}
// Render starred first, then unstarred
const allCards = [...starred, ...unstarred];
return m(
'.pf-recent-graphs-section.pf-nav-section',
m('.pf-nav-section-header', m('span', 'Recent graphs')),
allCards.length > 0
? m(
'.pf-recent-graphs-scroll-container',
m(
CardStack,
allCards.map(({entry, index}) =>
m(RecentGraphCard, {
key: index,
entry,
index,
onLoadGraph: attrs.onLoadGraph,
}),
),
),
)
: m('.pf-recent-graphs-empty', 'No recent graphs'),
);
}
}