Skip to content

Commit d3e5ab5

Browse files
committed
feat: persist recent projects with reopen UI
Recent projects now survive reloads via localStorage and are reopenable from an Open dropdown, including project URLs over http. Adds store helpers to remember, forget, and clear entries.
1 parent eb22f34 commit d3e5ab5

7 files changed

Lines changed: 239 additions & 21 deletions

File tree

apps/geolibre-desktop/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DesktopShell } from "./components/layout/DesktopShell";
22
import { useLayoutOptions } from "./hooks/useLayoutOptions";
33
import { usePlugins } from "./hooks/usePlugins";
44
import { useProjectUrlLoader } from "./hooks/useProjectUrlLoader";
5+
import { useRecentProjectsPersistence } from "./hooks/useRecentProjectsPersistence";
56
import { useThemeMode } from "./hooks/useThemeMode";
67

78
export default function App() {
@@ -10,6 +11,7 @@ export default function App() {
1011
const projectUrlLoadState = useProjectUrlLoader();
1112

1213
usePlugins();
14+
useRecentProjectsPersistence();
1315
return (
1416
<DesktopShell
1517
layoutOptions={layoutOptions}

apps/geolibre-desktop/src/components/layout/TopToolbar.tsx

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
import {
4343
Database,
4444
FolderOpen,
45+
History,
4546
Layers,
4647
Map,
4748
Moon,
@@ -54,7 +55,11 @@ import {
5455
import { useState, useSyncExternalStore } from "react";
5556
import { createAppAPI, usePluginRegistry } from "../../hooks/usePlugins";
5657
import type { ThemeMode } from "../../hooks/useThemeMode";
57-
import { openProjectFile, saveProjectFile } from "../../lib/tauri-io";
58+
import {
59+
openProjectFile,
60+
openRecentProjectFile,
61+
saveProjectFile,
62+
} from "../../lib/tauri-io";
5863
import { resolveProjectXyzLayers } from "../../lib/xyz-url";
5964
import { AddDataDialog, type AddDataKind } from "./AddDataDialog";
6065
import { AboutDialog } from "./AboutDialog";
@@ -95,6 +100,22 @@ const PLUGIN_POSITION_ITEMS: Array<{
95100
{ value: "bottom-right", label: "Bottom right" },
96101
];
97102

103+
function projectPathLabel(path: string): string {
104+
return path.split(/[/\\]/).pop() || path;
105+
}
106+
107+
function formatRecentProjectTime(openedAt: string): string {
108+
const openedDate = new Date(openedAt);
109+
if (Number.isNaN(openedDate.getTime())) return "";
110+
111+
return new Intl.DateTimeFormat(undefined, {
112+
month: "short",
113+
day: "numeric",
114+
hour: "numeric",
115+
minute: "2-digit",
116+
}).format(openedDate);
117+
}
118+
98119
export function TopToolbar({
99120
compact = false,
100121
mapControllerRef,
@@ -107,8 +128,12 @@ export function TopToolbar({
107128
const setProcessingOpen = useAppStore((s) => s.setProcessingOpen);
108129
const projectName = useAppStore((s) => s.projectName);
109130
const projectPath = useAppStore((s) => s.projectPath);
131+
const recentProjects = useAppStore((s) => s.recentProjects);
110132
const setProjectPath = useAppStore((s) => s.setProjectPath);
111133
const setProjectName = useAppStore((s) => s.setProjectName);
134+
const rememberRecentProject = useAppStore((s) => s.rememberRecentProject);
135+
const forgetRecentProject = useAppStore((s) => s.forgetRecentProject);
136+
const clearRecentProjects = useAppStore((s) => s.clearRecentProjects);
112137
const markSaved = useAppStore((s) => s.markSaved);
113138
const [controlsVisible, setControlsVisible] = useState<
114139
Record<ToolbarMapControl, boolean>
@@ -140,6 +165,34 @@ export function TopToolbar({
140165
}
141166
};
142167

168+
const handleOpenRecent = async (path: string) => {
169+
let result: Awaited<ReturnType<typeof openRecentProjectFile>>;
170+
171+
try {
172+
result = await openRecentProjectFile(path);
173+
} catch (error) {
174+
forgetRecentProject(path);
175+
console.error("Failed to open recent project", error);
176+
window.alert(
177+
error instanceof Error
178+
? error.message
179+
: "Could not open the recent project.",
180+
);
181+
return;
182+
}
183+
184+
try {
185+
loadProject(await resolveProjectXyzLayers(result.project), result.path);
186+
} catch (error) {
187+
console.error("Failed to load recent project", error);
188+
window.alert(
189+
error instanceof Error
190+
? error.message
191+
: "Could not load the recent project.",
192+
);
193+
}
194+
};
195+
143196
const handleSave = async (): Promise<boolean> => {
144197
const state = useAppStore.getState();
145198
const defaultProjectName = state.projectName.trim() || "Untitled Project";
@@ -159,6 +212,11 @@ export function TopToolbar({
159212
);
160213
if (!path) return false;
161214
setProjectPath(path);
215+
rememberRecentProject({
216+
path,
217+
name: project.name,
218+
openedAt: new Date().toISOString(),
219+
});
162220
markSaved();
163221
return true;
164222
};
@@ -244,16 +302,62 @@ export function TopToolbar({
244302
showLabels={showLabels}
245303
onSaveCurrentProject={handleSave}
246304
/>
247-
<Button
248-
className={toolbarButtonClass}
249-
variant="ghost"
250-
size={toolbarButtonSize}
251-
onClick={handleOpen}
252-
aria-label="Open"
253-
>
254-
<FolderOpen className={toolbarIconClassName} />
255-
{renderToolbarLabel("Open")}
256-
</Button>
305+
<DropdownMenu>
306+
<DropdownMenuTrigger asChild>
307+
<Button
308+
className={toolbarButtonClass}
309+
variant="ghost"
310+
size={toolbarButtonSize}
311+
aria-label="Open"
312+
>
313+
<FolderOpen className={toolbarIconClassName} />
314+
{renderToolbarLabel("Open")}
315+
</Button>
316+
</DropdownMenuTrigger>
317+
<DropdownMenuContent align="start" className="w-80">
318+
<DropdownMenuLabel>Project</DropdownMenuLabel>
319+
<DropdownMenuSeparator />
320+
<DropdownMenuItem onSelect={() => void handleOpen()}>
321+
<FolderOpen className="mr-2 h-3.5 w-3.5" />
322+
Open Project...
323+
</DropdownMenuItem>
324+
<DropdownMenuSeparator />
325+
<DropdownMenuLabel>Recent projects</DropdownMenuLabel>
326+
{recentProjects.length === 0 ? (
327+
<DropdownMenuItem disabled>No recent projects</DropdownMenuItem>
328+
) : (
329+
recentProjects.map((project) => {
330+
const openedAt = formatRecentProjectTime(project.openedAt);
331+
return (
332+
<DropdownMenuItem
333+
key={project.path}
334+
className="flex-col items-start gap-0.5"
335+
onSelect={() => void handleOpenRecent(project.path)}
336+
>
337+
<span className="max-w-full truncate font-medium">
338+
{project.name || projectPathLabel(project.path)}
339+
</span>
340+
<span className="flex max-w-full items-center gap-1 text-xs text-muted-foreground">
341+
<History className="h-3 w-3 shrink-0" />
342+
<span className="truncate">
343+
{openedAt
344+
? `${openedAt} - ${project.path}`
345+
: project.path}
346+
</span>
347+
</span>
348+
</DropdownMenuItem>
349+
);
350+
})
351+
)}
352+
<DropdownMenuSeparator />
353+
<DropdownMenuItem
354+
disabled={recentProjects.length === 0}
355+
onSelect={clearRecentProjects}
356+
>
357+
Clear Recent Projects
358+
</DropdownMenuItem>
359+
</DropdownMenuContent>
360+
</DropdownMenu>
257361
<Button
258362
className={toolbarButtonClass}
259363
variant="ghost"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useAppStore, type RecentProjectEntry } from "@geolibre/core";
2+
import { useEffect } from "react";
3+
4+
const RECENT_PROJECTS_STORAGE_KEY = "geolibre.recentProjects";
5+
6+
function isRecentProjectEntry(value: unknown): value is RecentProjectEntry {
7+
if (!value || typeof value !== "object") return false;
8+
const candidate = value as Partial<RecentProjectEntry>;
9+
return (
10+
typeof candidate.path === "string" &&
11+
typeof candidate.name === "string" &&
12+
typeof candidate.openedAt === "string"
13+
);
14+
}
15+
16+
function loadRecentProjects(): RecentProjectEntry[] {
17+
const stored = window.localStorage.getItem(RECENT_PROJECTS_STORAGE_KEY);
18+
if (!stored) return [];
19+
20+
try {
21+
const parsed = JSON.parse(stored) as unknown;
22+
return Array.isArray(parsed) ? parsed.filter(isRecentProjectEntry) : [];
23+
} catch {
24+
return [];
25+
}
26+
}
27+
28+
function saveRecentProjects(projects: RecentProjectEntry[]) {
29+
window.localStorage.setItem(
30+
RECENT_PROJECTS_STORAGE_KEY,
31+
JSON.stringify(projects),
32+
);
33+
}
34+
35+
export function useRecentProjectsPersistence() {
36+
const setRecentProjects = useAppStore((state) => state.setRecentProjects);
37+
38+
useEffect(() => {
39+
setRecentProjects(loadRecentProjects());
40+
41+
return useAppStore.subscribe((state, previous) => {
42+
if (state.recentProjects !== previous.recentProjects) {
43+
saveRecentProjects(state.recentProjects);
44+
}
45+
});
46+
}, [setRecentProjects]);
47+
}

apps/geolibre-desktop/src/lib/tauri-io.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ function isAbortError(error: unknown): boolean {
9191
return error instanceof DOMException && error.name === "AbortError";
9292
}
9393

94+
function isHttpUrl(path: string): boolean {
95+
try {
96+
const url = new URL(path);
97+
return url.protocol === "http:" || url.protocol === "https:";
98+
} catch {
99+
return false;
100+
}
101+
}
102+
94103
function fileExtension(path: string): string {
95104
const name = browserSafeFileName(path).toLowerCase();
96105
if (name.endsWith(".geoparquet")) return "geoparquet";
@@ -532,6 +541,29 @@ export async function openProjectFile(): Promise<{
532541
return { project, path: selected };
533542
}
534543

544+
export async function openRecentProjectFile(path: string): Promise<{
545+
project: GeoLibreProject;
546+
path: string;
547+
}> {
548+
if (isHttpUrl(path)) {
549+
const response = await fetch(path);
550+
if (!response.ok) {
551+
throw new Error(
552+
`Could not load project URL: HTTP ${response.status} ${response.statusText}`,
553+
);
554+
}
555+
return { project: parseProject(await response.text()), path };
556+
}
557+
558+
if (!isTauri()) {
559+
throw new Error(
560+
"Recent local projects can only be reopened in GeoLibre Desktop.",
561+
);
562+
}
563+
564+
return { project: parseProject(await readTextFile(path)), path };
565+
}
566+
535567
export async function saveProjectFile(
536568
content: string,
537569
defaultName?: string,

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,4 @@ Use `toolbar=icons` when you only want icon-only toolbar buttons. `panels=hidden
9797

9898
## Project status
9999

100-
GeoLibre is an active prototype. Version 0.5.0 includes the map workspace, project format, plugin API, browser vector import, DuckDB-WASM Spatial loading, advanced Add Data workflows, MBTiles desktop support, ArcGIS layers, COG and GeoTIFF raster rendering, PMTiles, Zarr, LiDAR, and Gaussian splats. See the [roadmap](roadmap.md) for planned work on SQL workflows, persisted recent projects, the Python processing sidecar, and external plugin loading.
100+
GeoLibre is an active prototype. Version 0.5.0 includes the map workspace, project format, plugin API, browser vector import, DuckDB-WASM Spatial loading, advanced Add Data workflows, MBTiles desktop support, ArcGIS layers, COG and GeoTIFF raster rendering, PMTiles, Zarr, LiDAR, Gaussian splats, and persisted recent projects. See the [roadmap](roadmap.md) for planned work on SQL workflows, the Python processing sidecar, and external plugin loading.

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- [x] In-session recent project tracking
1515
- [x] Feature highlight from attribute table
1616
- [x] Optional zoom to selected feature
17-
- [ ] Recent projects UI and persistence
17+
- [x] Recent projects UI and persistence
1818

1919
## v0.3: Cloud-native formats
2020

packages/core/src/store.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export interface AppState {
5656
loadProject: (project: GeoLibreProject, path?: string | null) => void;
5757
setProjectPath: (path: string | null) => void;
5858
setProjectName: (name: string) => void;
59+
setRecentProjects: (projects: RecentProjectEntry[]) => void;
60+
rememberRecentProject: (entry: RecentProjectEntry) => void;
61+
forgetRecentProject: (path: string) => void;
62+
clearRecentProjects: () => void;
5963
markSaved: () => void;
6064

6165
addLayer: (layer: GeoLibreLayer, beforeLayerId?: string | null) => void;
@@ -74,6 +78,30 @@ export interface AppState {
7478
) => string;
7579
}
7680

81+
const MAX_RECENT_PROJECTS = 10;
82+
83+
function normalizeRecentProjects(
84+
projects: RecentProjectEntry[],
85+
): RecentProjectEntry[] {
86+
const seen = new Set<string>();
87+
const normalized: RecentProjectEntry[] = [];
88+
89+
for (const project of projects) {
90+
const path = project.path.trim();
91+
if (!path || seen.has(path)) continue;
92+
93+
const name = project.name.trim() || path.split(/[/\\]/).pop() || path;
94+
normalized.push({
95+
path,
96+
name,
97+
openedAt: project.openedAt || new Date().toISOString(),
98+
});
99+
seen.add(path);
100+
}
101+
102+
return normalized.slice(0, MAX_RECENT_PROJECTS);
103+
}
104+
77105
export const useAppStore = create<AppState>((set, get) => ({
78106
projectName: "Untitled Project",
79107
projectPath: null,
@@ -144,22 +172,27 @@ export const useAppStore = create<AppState>((set, get) => ({
144172
identifyLayerId: null,
145173
});
146174
if (path) {
147-
const entry: RecentProjectEntry = {
175+
get().rememberRecentProject({
148176
path,
149177
name: project.name,
150178
openedAt: new Date().toISOString(),
151-
};
152-
set((s) => ({
153-
recentProjects: [
154-
entry,
155-
...s.recentProjects.filter((r) => r.path !== path),
156-
].slice(0, 10),
157-
}));
179+
});
158180
}
159181
},
160182

161183
setProjectPath: (path) => set({ projectPath: path }),
162184
setProjectName: (name) => set({ projectName: name, isDirty: true }),
185+
setRecentProjects: (projects) =>
186+
set({ recentProjects: normalizeRecentProjects(projects) }),
187+
rememberRecentProject: (entry) =>
188+
set((s) => ({
189+
recentProjects: normalizeRecentProjects([entry, ...s.recentProjects]),
190+
})),
191+
forgetRecentProject: (path) =>
192+
set((s) => ({
193+
recentProjects: s.recentProjects.filter((project) => project.path !== path),
194+
})),
195+
clearRecentProjects: () => set({ recentProjects: [] }),
163196
markSaved: () => set({ isDirty: false }),
164197

165198
addLayer: (layer, beforeLayerId = null) =>

0 commit comments

Comments
 (0)