feat: custom scrollbars, portable storage, window state persistence

- Custom scrollbar CSS using ::-webkit-scrollbar for Tauri's Chromium WebView
- Portable storage: all data written next to exe in data/ folder instead of AppData
- Rust get_portable_data_dir command with runtime FS scope for exe directory
- Window size/position/maximized saved to settings on close, restored on startup
This commit is contained in:
Your Name
2026-02-15 22:18:50 +02:00
parent bc12b5569a
commit c6fea186ef
8 changed files with 130 additions and 9 deletions

View File

@@ -17,8 +17,9 @@
"dialog:default", "dialog:default",
"shell:default", "shell:default",
"fs:default", "fs:default",
"fs:allow-appdata-read-recursive", "core:window:allow-set-size",
"fs:allow-appdata-write-recursive", "core:window:allow-set-position",
"fs:allow-appdata-meta-recursive" "core:window:allow-outer-size",
"core:window:allow-outer-position"
] ]
} }

View File

@@ -1,3 +1,15 @@
use tauri_plugin_fs::FsExt;
#[tauri::command]
fn get_portable_data_dir() -> Result<String, String> {
let exe_path = std::env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path
.parent()
.ok_or_else(|| "Failed to get exe directory".to_string())?;
let data_dir = exe_dir.join("data");
Ok(data_dir.to_string_lossy().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -5,6 +17,27 @@ pub fn run() {
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.setup(|app| {
// Get portable data directory next to the exe
let exe_path =
std::env::current_exe().expect("Failed to get exe path");
let exe_dir = exe_path
.parent()
.expect("Failed to get exe directory");
let data_dir = exe_dir.join("data");
// Ensure data directory exists
std::fs::create_dir_all(&data_dir)
.expect("Failed to create portable data directory");
// Allow FS plugin access to the portable data directory
app.fs_scope()
.allow_directory(&data_dir, true)
.expect("Failed to allow data directory in FS scope");
Ok(())
})
.invoke_handler(tauri::generate_handler![get_portable_data_dir])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { getCurrentWindow, LogicalSize, LogicalPosition } from "@tauri-apps/api/window";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion"; import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { saveSettings } from "@/lib/storage";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { BoardList } from "@/components/boards/BoardList"; import { BoardList } from "@/components/boards/BoardList";
import { BoardView } from "@/components/board/BoardView"; import { BoardView } from "@/components/board/BoardView";
@@ -21,13 +23,46 @@ export default function App() {
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false); const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
useEffect(() => { useEffect(() => {
init(); init().then(() => {
// Restore window state after settings are loaded
const { settings } = useAppStore.getState();
const ws = settings.windowState;
if (ws) {
const appWindow = getCurrentWindow();
if (ws.maximized) {
appWindow.maximize();
} else {
appWindow.setSize(new LogicalSize(ws.width, ws.height));
appWindow.setPosition(new LogicalPosition(ws.x, ws.y));
}
}
});
}, [init]); }, [init]);
// Flush pending board saves before the app window closes // Save window state + flush board saves before the app window closes
useEffect(() => { useEffect(() => {
function handleBeforeUnload() { function handleBeforeUnload() {
useBoardStore.getState().closeBoard(); useBoardStore.getState().closeBoard();
// Save window state synchronously-ish (fire and forget)
const appWindow = getCurrentWindow();
Promise.all([
appWindow.outerSize(),
appWindow.outerPosition(),
appWindow.isMaximized(),
]).then(([size, position, maximized]) => {
const settings = useAppStore.getState().settings;
saveSettings({
...settings,
windowState: {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
maximized,
},
});
});
} }
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
return () => { return () => {

View File

@@ -148,6 +148,39 @@
.dark * { .dark * {
scrollbar-color: oklch(80% 0 0 / 15%) transparent; scrollbar-color: oklch(80% 0 0 / 15%) transparent;
} }
/* Custom scrollbar for Chromium/WebKit (Tauri WebView) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(50% 0 0 / 20%);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(50% 0 0 / 35%);
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(80% 0 0 / 15%);
border: 2px solid transparent;
background-clip: content-box;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(80% 0 0 / 30%);
border: 2px solid transparent;
background-clip: content-box;
}
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Satoshi", system-ui, -apple-system, sans-serif; font-family: "Satoshi", system-ui, -apple-system, sans-serif;

View File

@@ -57,6 +57,14 @@ export const boardSchema = z.object({
settings: boardSettingsSchema.default({ attachmentMode: "link", background: "none" }), settings: boardSettingsSchema.default({ attachmentMode: "link", background: "none" }),
}); });
export const windowStateSchema = z.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
maximized: z.boolean(),
});
export const appSettingsSchema = z.object({ export const appSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]).default("system"), theme: z.enum(["light", "dark", "system"]).default("system"),
dataDirectory: z.string().nullable().default(null), dataDirectory: z.string().nullable().default(null),
@@ -65,4 +73,5 @@ export const appSettingsSchema = z.object({
uiZoom: z.number().min(0.75).max(1.5).default(1), uiZoom: z.number().min(0.75).max(1.5).default(1),
density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"), density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
defaultColumnWidth: z.enum(["narrow", "standard", "wide"]).default("standard"), defaultColumnWidth: z.enum(["narrow", "standard", "wide"]).default("standard"),
windowState: windowStateSchema.nullable().default(null),
}); });

View File

@@ -7,18 +7,18 @@ import {
remove, remove,
copyFile, copyFile,
} from "@tauri-apps/plugin-fs"; } from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/api/path"; import { join } from "@tauri-apps/api/path";
import { invoke } from "@tauri-apps/api/core";
import { boardSchema, appSettingsSchema } from "./schemas"; import { boardSchema, appSettingsSchema } from "./schemas";
import type { Board, BoardMeta } from "@/types/board"; import type { Board, BoardMeta } from "@/types/board";
import type { AppSettings } from "@/types/settings"; import type { AppSettings } from "@/types/settings";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path helpers // Path helpers — portable: all data lives next to the exe
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function getBaseDir(): Promise<string> { async function getBaseDir(): Promise<string> {
const base = await appDataDir(); return invoke<string>("get_portable_data_dir");
return join(base, "openpylon");
} }
async function getBoardsDir(): Promise<string> { async function getBoardsDir(): Promise<string> {

View File

@@ -62,6 +62,7 @@ export const useAppStore = create<AppState>((set, get) => ({
uiZoom: 1, uiZoom: 1,
density: "comfortable", density: "comfortable",
defaultColumnWidth: "standard", defaultColumnWidth: "standard",
windowState: null,
}, },
boards: [], boards: [],
view: { type: "board-list" }, view: { type: "board-list" },

View File

@@ -1,5 +1,13 @@
import type { ColumnWidth } from "./board"; import type { ColumnWidth } from "./board";
export interface WindowState {
x: number;
y: number;
width: number;
height: number;
maximized: boolean;
}
export interface AppSettings { export interface AppSettings {
theme: "light" | "dark" | "system"; theme: "light" | "dark" | "system";
dataDirectory: string | null; dataDirectory: string | null;
@@ -8,4 +16,5 @@ export interface AppSettings {
uiZoom: number; uiZoom: number;
density: "compact" | "comfortable" | "spacious"; density: "compact" | "comfortable" | "spacious";
defaultColumnWidth: ColumnWidth; defaultColumnWidth: ColumnWidth;
windowState: WindowState | null;
} }