Skip to content

Commit e90aaf8

Browse files
committed
frontend/aria/hotkey: recent files and bugfixes
1 parent 436f9bd commit e90aaf8

File tree

4 files changed

+191
-49
lines changed

4 files changed

+191
-49
lines changed

src/packages/frontend/app/hotkey/build-tree.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface ProjectInfo {
5858
files: FileInfo[];
5959
pages: PageInfo[];
6060
starredFiles?: string[]; // Paths to starred files in this project
61+
recentFiles?: string[]; // Paths to recently opened files in this project (limit 10)
6162
}
6263

6364
export type AppPageAction = "tab" | "toggle-file-use";
@@ -355,7 +356,46 @@ function buildProjectNode(
355356
});
356357
}
357358

358-
// 3. Pages section with nested pages list
359+
// 3. Recent files section (if any)
360+
if (project.recentFiles && project.recentFiles.length > 0) {
361+
const recentFileChildren: NavigationTreeNode[] = project.recentFiles.map(
362+
(filePath) => {
363+
const fileName = filePath.split("/").pop() || filePath;
364+
const fileIcon = filenameIcon(filePath);
365+
return {
366+
key: `recent-file-${project.id}-${filePath}`,
367+
title: (
368+
<span className="tree-node-ellipsis" title={filePath}>
369+
{fileName}
370+
</span>
371+
),
372+
icon: <Icon name={fileIcon} />,
373+
navigationData: {
374+
type: "file",
375+
id: filePath,
376+
projectId: project.id,
377+
filePath,
378+
searchText: filePath,
379+
action: async () => {
380+
// Will be set by caller with actual actions
381+
},
382+
},
383+
};
384+
},
385+
);
386+
387+
children.push({
388+
key: `project-${project.id}-recent-files`,
389+
title: (
390+
<>
391+
<Icon name="history" /> Recent
392+
</>
393+
),
394+
children: recentFileChildren,
395+
});
396+
}
397+
398+
// 4. Pages section with nested pages list
359399
if (project.pages.length > 0) {
360400
const pageChildren: NavigationTreeNode[] = [];
361401

src/packages/frontend/app/hotkey/dialog.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -806,13 +806,12 @@ export const QuickNavigationDialog: React.FC<QuickNavigationDialogProps> = ({
806806

807807
<div
808808
ref={treeContainerRef}
809+
onClick={(e) => e.stopPropagation()}
809810
style={{
810811
flex: 1,
811812
overflow: "auto",
812813
minHeight: 0,
813814
minWidth: 0,
814-
// Suppress Ant Design Tree's default selection styling (border/background)
815-
// We only use bold text for selection
816815
}}
817816
className="quick-nav-tree"
818817
>
@@ -828,7 +827,7 @@ export const QuickNavigationDialog: React.FC<QuickNavigationDialogProps> = ({
828827
onSelect={async (keys, info) => {
829828
const newKey = keys[0] || null;
830829

831-
// Search in transformedTreeData (full tree with parents) not searchList (only leaves)
830+
// Search in transformedTreeData (full tree with parents) to check if leaf
832831
const findNode = (
833832
nodes: NavigationTreeNode[],
834833
): NavigationTreeNode | undefined => {
@@ -842,11 +841,11 @@ export const QuickNavigationDialog: React.FC<QuickNavigationDialogProps> = ({
842841
return undefined;
843842
};
844843

845-
const targetNode = findNode(transformedTreeData);
844+
const treeNode = findNode(transformedTreeData);
846845

847846
// Check if this is a leaf node (no children)
848847
const isLeaf =
849-
!targetNode?.children || targetNode.children.length === 0;
848+
!treeNode?.children || treeNode.children.length === 0;
850849

851850
// Only allow selection and interaction for leaf nodes
852851
if (!isLeaf) {
@@ -876,9 +875,16 @@ export const QuickNavigationDialog: React.FC<QuickNavigationDialogProps> = ({
876875

877876
// If selected via mouse click on a leaf node, activate it immediately
878877
// The info.event object is only present when user clicks
879-
if (info.event && targetNode?.navigationData) {
880-
await triggerAction(targetNode.navigationData.action);
881-
onClose();
878+
if (info.event) {
879+
// Use filteredSearchList (same source as Return key handler)
880+
// This ensures navigationData is properly populated
881+
const listNode = filteredSearchList.find(
882+
(item) => item.key === newKey,
883+
);
884+
if (listNode?.node.navigationData) {
885+
await triggerAction(listNode.node.navigationData.action);
886+
onClose();
887+
}
882888
}
883889
}}
884890
/>

src/packages/frontend/app/hotkey/use-navigation-data.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
declare var DEBUG: boolean;
77

8-
import { useMemo } from "react";
8+
import { useEffect, useMemo } from "react";
99
import { useIntl } from "react-intl";
1010

1111
import { switchAccountPage } from "@cocalc/frontend/account/util";
@@ -41,6 +41,7 @@ import {
4141
focusFrameWithRetry,
4242
resolveSpecLabel,
4343
} from "./util";
44+
import { getRecentFilesList } from "@cocalc/frontend/projects/util";
4445

4546
const PROJECT_PAGE_INFOS: PageInfo[] = Object.entries(FIXED_PROJECT_TABS)
4647
.filter(([tabId]) => tabId !== "active")
@@ -210,6 +211,10 @@ export function useNavigationTreeData(
210211
{ project_id: project_id ?? "" },
211212
"starred_files",
212213
);
214+
const project_log = useTypedRedux(
215+
{ project_id: project_id ?? "" },
216+
"project_log",
217+
);
213218

214219
// Only use these if we're in a project view
215220
const starred_files = isProjectView ? starred_files_raw : undefined;
@@ -315,7 +320,7 @@ export function useNavigationTreeData(
315320
if (starred_files) {
316321
const allStarred = Array.isArray(starred_files)
317322
? starred_files
318-
: starred_files.toArray?.() ?? [];
323+
: (starred_files.toArray?.() ?? []);
319324
starredFiles = allStarred.filter(
320325
(path) => !openFilePaths.has(path),
321326
);
@@ -328,20 +333,43 @@ export function useNavigationTreeData(
328333
if (otherStarredFiles) {
329334
const allStarred = Array.isArray(otherStarredFiles)
330335
? otherStarredFiles
331-
: otherStarredFiles.toArray?.() ?? [];
336+
: (otherStarredFiles.toArray?.() ?? []);
332337
starredFiles = allStarred.filter(
333338
(path) => !openFilePaths.has(path),
334339
);
335340
}
336341
}
337342
}
338343

344+
// Get recent files for this project (excluding already-open files)
345+
let recentFiles: string[] = [];
346+
if (isCurrentProject) {
347+
// Current project: get from Redux project_log
348+
if (project_log) {
349+
const allRecent = getRecentFilesList(project_log, 10);
350+
recentFiles = allRecent.filter((path) => !openFilePaths.has(path));
351+
}
352+
} else {
353+
// Other projects: get from their project store if available
354+
const otherProjectStore = redux.getProjectStore(projectId);
355+
if (otherProjectStore) {
356+
const otherProjectLog = otherProjectStore.get("project_log");
357+
if (otherProjectLog) {
358+
const allRecent = getRecentFilesList(otherProjectLog, 10);
359+
recentFiles = allRecent.filter(
360+
(path) => !openFilePaths.has(path),
361+
);
362+
}
363+
}
364+
}
365+
339366
return {
340367
id: projectId,
341368
title: proj.get("title") || projectId,
342369
files,
343370
pages: PROJECT_PAGE_INFOS,
344371
starredFiles,
372+
recentFiles,
345373
} as ProjectInfo;
346374
})
347375
.filter((p): p is ProjectInfo => p !== null);
@@ -353,6 +381,7 @@ export function useNavigationTreeData(
353381
open_files,
354382
open_files_order,
355383
starred_files,
384+
project_log,
356385
]);
357386

358387
// Get bookmarked projects
@@ -374,14 +403,15 @@ export function useNavigationTreeData(
374403
return null;
375404
}
376405

377-
// For bookmarked projects, we don't need open files, pages, or starred files
406+
// For bookmarked projects, we don't need open files, pages, starred files, or recent files
378407
// (they're not open, so those are unavailable)
379408
return {
380409
id: projectId,
381410
title: proj.get("title") || projectId,
382411
files: [],
383412
pages: [],
384413
starredFiles: [],
414+
recentFiles: [],
385415
} as ProjectInfo;
386416
})
387417
.filter((p): p is ProjectInfo => p !== null);
@@ -426,6 +456,32 @@ export function useNavigationTreeData(
426456
return pages;
427457
}, [skip, intl, is_logged_in, is_anonymous]);
428458

459+
// Initialize project_log for all open projects so recent files are available
460+
// This needs to happen in useEffect (not useMemo) because init_table is a side effect
461+
useEffect(() => {
462+
if (skip || !open_projects) {
463+
return;
464+
}
465+
466+
// Initialize project_log for the current project
467+
if (project_id) {
468+
const store = redux.getProjectStore(project_id);
469+
if (store) {
470+
store.init_table("project_log");
471+
}
472+
}
473+
474+
// Initialize project_log for all other open projects
475+
open_projects.forEach((projectId: string) => {
476+
if (projectId !== project_id) {
477+
const store = redux.getProjectStore(projectId);
478+
if (store) {
479+
store.init_table("project_log");
480+
}
481+
}
482+
});
483+
}, [skip, project_id, open_projects]);
484+
429485
// Build the complete navigation tree
430486
const treeData = useMemo(() => {
431487
if (skip) {
@@ -719,15 +775,9 @@ export function useEnhancedNavigationTreeData(
719775
case "tab":
720776
page_actions?.set_active_tab(navData.id);
721777
break;
722-
case "toggle-file-use": {
723-
const showFileUse = redux
724-
.getStore("page")
725-
?.get("show_file_use");
726-
if (!showFileUse) {
727-
page_actions?.toggle_show_file_use();
728-
}
778+
case "toggle-file-use":
779+
page_actions?.toggle_show_file_use();
729780
break;
730-
}
731781
default:
732782
unreachable(navData.appPageAction);
733783
}

src/packages/frontend/projects/util.tsx

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -251,24 +251,19 @@ export interface OpenedFile {
251251
}
252252

253253
/**
254-
* React hook to get recent files from project log with deduplication and optional search filtering
254+
* Core helper to get sorted, deduplicated entries from project log
255+
* Used by both getRecentFilesList and useRecentFiles to avoid duplication
255256
*
256-
* @param project_log - The project log from redux store
257-
* @param max - Maximum number of files to return (default: 100)
258-
* @param searchTerm - Optional search term to filter filenames (case-insensitive)
259-
* @returns Array of recent opened files
257+
* @param projectLog - The project log from redux store (Immutable structure)
258+
* @returns Sorted and deduplicated project log entries
260259
*/
261-
export function useRecentFiles(
262-
project_log: any,
263-
max: number = 100,
264-
searchTerm: string = "",
265-
): OpenedFile[] {
266-
return useMemo(() => {
267-
if (project_log == null || max === 0) return [];
260+
function getSortedRecentEntries(projectLog: any): any[] {
261+
if (projectLog == null) return [];
268262

269-
const dedupe: string[] = [];
263+
const dedupe: Set<string> = new Set();
270264

271-
return project_log
265+
try {
266+
return projectLog
272267
.valueSeq()
273268
.filter(
274269
(entry: EventRecordMap) =>
@@ -278,23 +273,74 @@ export function useRecentFiles(
278273
.sort((a, b) => getTime(b) - getTime(a))
279274
.filter((entry: EventRecordMap) => {
280275
const fn = entry.getIn(["event", "filename"]);
281-
if (dedupe.includes(fn)) return false;
282-
dedupe.push(fn);
276+
if (!fn || dedupe.has(fn)) return false;
277+
dedupe.add(fn);
283278
return true;
284279
})
285-
.filter((entry: EventRecordMap) =>
286-
entry
287-
.getIn(["event", "filename"], "")
288-
.toLowerCase()
289-
.includes(searchTerm.toLowerCase()),
290-
)
280+
.toArray();
281+
} catch (err) {
282+
console.log("Error sorting recent entries", err);
283+
return [];
284+
}
285+
}
286+
287+
/**
288+
* Extract recent files from project log (non-hook utility for use across the codebase)
289+
* Returns only filenames for quick navigation use
290+
*
291+
* @param projectLog - The project log from redux store (Immutable structure)
292+
* @param max - Maximum number of files to return (default: 10)
293+
* @returns Array of filenames in reverse chronological order
294+
*/
295+
export function getRecentFilesList(
296+
projectLog: any,
297+
max: number = 10,
298+
): string[] {
299+
try {
300+
return getSortedRecentEntries(projectLog)
291301
.slice(0, max)
292-
.map((entry: EventRecordMap) => ({
293-
filename: entry.getIn(["event", "filename"]),
294-
time: entry.get("time"),
295-
account_id: entry.get("account_id"),
296-
}))
297-
.toJS() as OpenedFile[];
302+
.map((entry: EventRecordMap) => entry.getIn(["event", "filename"]))
303+
.filter((fn: string) => !!fn) as string[];
304+
} catch (err) {
305+
console.log("Error extracting recent files list", err);
306+
return [];
307+
}
308+
}
309+
310+
/**
311+
* React hook to get recent files from project log with deduplication and optional search filtering
312+
*
313+
* @param project_log - The project log from redux store
314+
* @param max - Maximum number of files to return (default: 100)
315+
* @param searchTerm - Optional search term to filter filenames (case-insensitive)
316+
* @returns Array of recent opened files
317+
*/
318+
export function useRecentFiles(
319+
project_log: any,
320+
max: number = 100,
321+
searchTerm: string = "",
322+
): OpenedFile[] {
323+
return useMemo(() => {
324+
if (project_log == null || max === 0) return [];
325+
326+
try {
327+
return getSortedRecentEntries(project_log)
328+
.filter((entry: EventRecordMap) =>
329+
entry
330+
.getIn(["event", "filename"], "")
331+
.toLowerCase()
332+
.includes(searchTerm.toLowerCase()),
333+
)
334+
.slice(0, max)
335+
.map((entry: EventRecordMap) => ({
336+
filename: entry.getIn(["event", "filename"]),
337+
time: entry.get("time"),
338+
account_id: entry.get("account_id"),
339+
})) as OpenedFile[];
340+
} catch (err) {
341+
console.log("Error extracting recent files", err);
342+
return [];
343+
}
298344
}, [project_log, max, searchTerm]);
299345
}
300346

0 commit comments

Comments
 (0)