diff --git a/package.json b/package.json
index c99b6fc..9edd949 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "stratum",
"private": true,
- "version": "0.1.7",
+ "version": "0.1.8",
"type": "module",
"scripts": {
"dev": "vite",
@@ -21,6 +21,7 @@
"tinykeys": "^3.0.0"
},
"devDependencies": {
+ "@types/node": "^24.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/debug": "^4.1.12",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 92ee99f..e064976 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,6 +39,9 @@ importers:
'@types/debug':
specifier: ^4.1.12
version: 4.1.12
+ '@types/node':
+ specifier: ^24.3.0
+ version: 24.11.0
'@types/react':
specifier: ^19.1.8
version: 19.2.9
@@ -47,7 +50,7 @@ importers:
version: 19.2.3(@types/react@19.2.9)
'@vitejs/plugin-react':
specifier: ^4.6.0
- version: 4.7.0(vite@7.3.1(sass@1.97.2))
+ version: 4.7.0(vite@7.3.1(@types/node@24.11.0)(sass@1.97.2))
sass:
specifier: ^1.80.7
version: 1.97.2
@@ -56,7 +59,7 @@ importers:
version: 5.8.3
vite:
specifier: ^7.0.4
- version: 7.3.1(sass@1.97.2)
+ version: 7.3.1(@types/node@24.11.0)(sass@1.97.2)
packages:
@@ -626,6 +629,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+ '@types/node@24.11.0':
+ resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -807,6 +813,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1311,6 +1320,10 @@ snapshots:
'@types/ms@2.1.0': {}
+ '@types/node@24.11.0':
+ dependencies:
+ undici-types: 7.16.0
+
'@types/react-dom@19.2.3(@types/react@19.2.9)':
dependencies:
'@types/react': 19.2.9
@@ -1319,7 +1332,7 @@ snapshots:
dependencies:
csstype: 3.2.3
- '@vitejs/plugin-react@4.7.0(vite@7.3.1(sass@1.97.2))':
+ '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@24.11.0)(sass@1.97.2))':
dependencies:
'@babel/core': 7.28.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
@@ -1327,7 +1340,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
- vite: 7.3.1(sass@1.97.2)
+ vite: 7.3.1(@types/node@24.11.0)(sass@1.97.2)
transitivePeerDependencies:
- supports-color
@@ -1504,6 +1517,8 @@ snapshots:
typescript@5.8.3: {}
+ undici-types@7.16.0: {}
+
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@@ -1514,7 +1529,7 @@ snapshots:
dependencies:
react: 19.2.3
- vite@7.3.1(sass@1.97.2):
+ vite@7.3.1(@types/node@24.11.0)(sass@1.97.2):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@@ -1523,6 +1538,7 @@ snapshots:
rollup: 4.55.2
tinyglobby: 0.2.15
optionalDependencies:
+ '@types/node': 24.11.0
fsevents: 2.3.3
sass: 1.97.2
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 1a151b5..182f592 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3632,7 +3632,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stratum"
-version = "0.1.7"
+version = "0.1.8"
dependencies = [
"flate2",
"image",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 6e76862..f1d9b72 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "stratum"
-version = "0.1.7"
+version = "0.1.8"
description = "Stratum File Manager"
authors = ["emy"]
edition = "2021"
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 76e9053..5ae82fb 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -5,8 +5,12 @@
"windows": ["main"],
"permissions": [
"core:default",
+ "core:window:allow-close",
+ "core:window:allow-minimize",
"core:window:allow-set-size",
+ "core:window:allow-start-dragging",
"core:window:allow-set-title",
+ "core:window:allow-toggle-maximize",
"core:window:allow-destroy",
"dialog:default",
{
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index b692c33..f591ba8 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,11 +1,11 @@
-{
- "$schema": "https://schema.tauri.app/config/2",
- "productName": "Stratum",
- "version": "0.1.7",
- "identifier": "com.emy.stratum",
- "build": {
- "beforeDevCommand": "pnpm dev",
- "devUrl": "http://localhost:1421",
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "productName": "Stratum",
+ "version": "0.1.8",
+ "identifier": "com.emy.stratum",
+ "build": {
+ "beforeDevCommand": "pnpm dev",
+ "devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
@@ -14,12 +14,14 @@
{
"title": "Stratum",
"width": 1200,
- "height": 760,
- "minWidth": 900,
- "minHeight": 600,
- "dragDropEnabled": false,
- "backgroundColor": "#0a0b10"
- }
+ "height": 760,
+ "minWidth": 900,
+ "minHeight": 600,
+ "dragDropEnabled": false,
+ "decorations": false,
+ "shadow": true,
+ "backgroundColor": "#0a0b10"
+ }
],
"security": {
"csp": null,
diff --git a/src/App.tsx b/src/App.tsx
index a08925e..e5e9b69 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,6 @@
// App shell wiring: composes state hooks, layout blocks, and overlays.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { AppOverlays, AppShellLayout } from "@/components";
+import { AppOverlays, AppShellLayout, AppWindowFrame } from "@/components";
import {
useAppCommands,
useAppContextMenuSection,
@@ -435,13 +435,12 @@ const App = () => {
},
settings: {
confirmClose: settings.confirmClose,
- accentTheme: settings.accentTheme,
- ambientBackground: settings.ambientBackground,
- blurOverlays: settings.blurOverlays,
- gridRounded: settings.gridRounded,
- gridCentered: settings.gridCentered,
- compactMode: settings.compactMode,
- },
+ accentTheme: settings.accentTheme,
+ ambientBackground: settings.ambientBackground,
+ blurOverlays: settings.blurOverlays,
+ gridRounded: settings.gridRounded,
+ gridCentered: settings.gridCentered,
+ },
view: {
activeTabId,
activeTabPath,
@@ -698,7 +697,7 @@ const App = () => {
scrollRestoreTop,
scrollRequest,
smoothScroll: settings.smoothScroll,
- compactMode: settings.compactMode,
+ pendingDeletePaths: fileManager.pendingDeletePaths,
sortState,
canGoUp,
},
@@ -837,9 +836,9 @@ const App = () => {
return (
);
};
diff --git a/src/components/app/AppOverlays.tsx b/src/components/app/AppOverlays.tsx
index 3e82c98..f3f10d5 100644
--- a/src/components/app/AppOverlays.tsx
+++ b/src/components/app/AppOverlays.tsx
@@ -24,7 +24,7 @@ const LazyQuickPreviewOverlay = lazy(async () => {
return { default: module.QuickPreviewOverlay };
});
-const useDeferredMount = (open: boolean) => {
+const useDeferredMount = (open: boolean, prewarmOnIdle = false) => {
const [mounted, setMounted] = useState(open);
useEffect(() => {
@@ -33,6 +33,46 @@ const useDeferredMount = (open: boolean) => {
}
}, [open]);
+ useEffect(() => {
+ if (mounted || open || !prewarmOnIdle) return;
+ if (typeof window === "undefined") return;
+
+ const idleWindow = window as Window & {
+ requestIdleCallback?: (
+ callback: IdleRequestCallback,
+ options?: IdleRequestOptions,
+ ) => number;
+ cancelIdleCallback?: (handle: number) => void;
+ };
+ let timeoutId: number | null = null;
+ let idleId: number | null = null;
+
+ const prewarm = () => {
+ setMounted(true);
+ };
+
+ // Warm hidden heavyweight overlays after the first paint so the first open feels instant.
+ if (typeof idleWindow.requestIdleCallback === "function") {
+ idleId = idleWindow.requestIdleCallback(() => {
+ prewarm();
+ }, { timeout: 1200 });
+ } else {
+ timeoutId = window.setTimeout(prewarm, 280);
+ }
+
+ return () => {
+ if (timeoutId != null) {
+ window.clearTimeout(timeoutId);
+ }
+ if (
+ idleId != null &&
+ typeof idleWindow.cancelIdleCallback === "function"
+ ) {
+ idleWindow.cancelIdleCallback(idleId);
+ }
+ };
+ }, [mounted, open, prewarmOnIdle]);
+
return mounted;
};
@@ -52,8 +92,8 @@ export const AppOverlays = ({
settings,
}: AppOverlaysProps) => {
// Load heavyweight overlays on first use, then keep them mounted.
- const conversionMounted = useDeferredMount(conversion.open);
- const settingsMounted = useDeferredMount(settings.open);
+ const conversionMounted = useDeferredMount(conversion.open, true);
+ const settingsMounted = useDeferredMount(settings.open, true);
const quickPreviewMounted = useDeferredMount(quickPreview.open);
return (
diff --git a/src/components/app/AppWindowFrame.tsx b/src/components/app/AppWindowFrame.tsx
new file mode 100644
index 0000000..0d0bc96
--- /dev/null
+++ b/src/components/app/AppWindowFrame.tsx
@@ -0,0 +1,4 @@
+// Global window chrome that stays visible above fullscreen overlay backdrops.
+export const AppWindowFrame = () => {
+ return
;
+};
diff --git a/src/components/app/index.ts b/src/components/app/index.ts
index 591a07f..0c6e09f 100644
--- a/src/components/app/index.ts
+++ b/src/components/app/index.ts
@@ -6,4 +6,5 @@ export { AppOverlays } from "./AppOverlays";
export { AppShellLayout } from "./AppShellLayout";
export { AppStatusbar } from "./AppStatusbar";
export { AppTopstack } from "./AppTopstack";
-export { AppTopstackContainer } from "./AppTopstackContainer";
+export { AppTopstackContainer } from "./AppTopstackContainer";
+export { AppWindowFrame } from "./AppWindowFrame";
diff --git a/src/components/explorer/FileGrid.tsx b/src/components/explorer/FileGrid.tsx
index 3ab6276..959580d 100644
--- a/src/components/explorer/FileGrid.tsx
+++ b/src/components/explorer/FileGrid.tsx
@@ -9,7 +9,7 @@ import {
useEntryPresence,
useScrollToIndex,
} from "@/hooks";
-import { getEmptyMessage, handleMiddleClick } from "@/lib";
+import { getEmptyMessage, handleMiddleClick, normalizePath } from "@/lib";
import type { DropTarget, EntryItem } from "@/lib";
import type { GridNameEllipsis, GridSize, ThumbnailFit } from "@/modules";
import type { EntryMeta, FileEntry, RenameCommitReason, ThumbnailRequest } from "@/types";
@@ -35,7 +35,7 @@ type FileGridProps = {
scrollRestoreTop: number;
scrollRequest?: { index: number; nonce: number } | null;
smoothScroll: boolean;
- compactMode: boolean;
+ pendingDeletePaths: Set;
selectedPaths: Set;
onSetSelection: (paths: string[], anchor?: string) => void;
onOpenDir: (path: string) => void;
@@ -107,7 +107,7 @@ const FileGrid = ({
scrollRestoreTop,
scrollRequest,
smoothScroll,
- compactMode,
+ pendingDeletePaths,
selectedPaths,
onSetSelection,
onOpenDir,
@@ -160,6 +160,13 @@ const FileGrid = ({
canGoUp,
onGoUp,
}: FileGridProps) => {
+ const isDeletePending = useCallback(
+ (path: string) => {
+ const key = normalizePath(path) ?? path.trim();
+ return key ? pendingDeletePaths.has(key) : false;
+ },
+ [pendingDeletePaths],
+ );
const emptyMessage = useMemo(() => getEmptyMessage(searchQuery), [searchQuery]);
const { items: viewItems } = useEntryPresence({
items,
@@ -170,9 +177,12 @@ const FileGrid = ({
() =>
viewItems.map((item) => ({
path: item.type === "parent" ? item.path : item.entry.path,
- selectable: item.type !== "parent" && item.presence !== "removed",
+ selectable:
+ item.type !== "parent" &&
+ item.presence !== "removed" &&
+ !isDeletePending(item.entry.path),
})),
- [viewItems],
+ [isDeletePending, viewItems],
);
const resolvedIndexMap = useMemo(() => {
if (indexMap) return indexMap;
@@ -225,7 +235,6 @@ const FileGrid = ({
columnCount,
rowCount,
rowHeight,
- compactMode,
viewItems,
});
@@ -269,7 +278,6 @@ const FileGrid = ({
columnWidth,
rowHeight,
gridGap,
- compactMode,
onSetSelection,
onClearSelection,
onStartDragOut,
@@ -436,6 +444,7 @@ const FileGrid = ({
nameEllipsis={gridNameEllipsis}
hideExtension={gridNameHideExtension}
selected={selectedPaths.has(item.entry.path)}
+ isDeleting={isDeletePending(item.entry.path)}
dropTarget={isDropTarget}
isRenaming={renameTargetPath === item.entry.path}
renameValue={renameValue}
diff --git a/src/components/explorer/FileList.tsx b/src/components/explorer/FileList.tsx
index a372024..9255dca 100644
--- a/src/components/explorer/FileList.tsx
+++ b/src/components/explorer/FileList.tsx
@@ -9,7 +9,7 @@ import {
useEntryPresence,
useScrollToIndex,
} from "@/hooks";
-import { getEmptyMessage, handleMiddleClick } from "@/lib";
+import { getEmptyMessage, handleMiddleClick, normalizePath } from "@/lib";
import type { DropTarget, EntryItem } from "@/lib";
import type { EntryMeta, FileEntry, RenameCommitReason, SortState } from "@/types";
import { EmptyState } from "@/components/primitives/EmptyState";
@@ -34,7 +34,7 @@ type FileListProps = {
scrollRestoreTop: number;
scrollRequest?: { index: number; nonce: number } | null;
smoothScroll: boolean;
- compactMode: boolean;
+ pendingDeletePaths: Set;
sortState: SortState;
onSortChange: (next: SortState) => void;
categoryTinting: boolean;
@@ -88,7 +88,7 @@ const FileList = ({
scrollRestoreTop,
scrollRequest,
smoothScroll,
- compactMode,
+ pendingDeletePaths,
sortState,
onSortChange,
categoryTinting,
@@ -123,6 +123,13 @@ const FileList = ({
canGoUp,
onGoUp,
}: FileListProps) => {
+ const isDeletePending = useCallback(
+ (path: string) => {
+ const key = normalizePath(path) ?? path.trim();
+ return key ? pendingDeletePaths.has(key) : false;
+ },
+ [pendingDeletePaths],
+ );
const emptyMessage = useMemo(() => getEmptyMessage(searchQuery), [searchQuery]);
const { items: rows } = useEntryPresence({
items,
@@ -133,9 +140,11 @@ const FileList = ({
() =>
rows.map((row) => ({
path: row.type === "parent" ? row.path : row.entry.path,
- selectable: row.presence !== "removed",
+ selectable:
+ row.presence !== "removed" &&
+ (row.type === "parent" || !isDeletePending(row.entry.path)),
})),
- [rows],
+ [isDeletePending, rows],
);
const resolvedIndexMap = useMemo(() => {
if (indexMap) return indexMap;
@@ -148,7 +157,6 @@ const FileList = ({
}, [indexMap, items]);
const { listRef, itemHeight, rowHeight, listVars } = useListLayout({
- compactMode,
smoothScroll,
scrollRestoreKey,
scrollRestoreTop,
@@ -159,7 +167,6 @@ const FileList = ({
listRef,
viewKey,
itemHeight,
- compactMode,
rows,
});
@@ -185,7 +192,6 @@ const FileList = ({
selectionItems,
itemHeight,
rowHeight,
- compactMode,
onSetSelection,
onClearSelection,
onStartDragOut,
@@ -352,6 +358,7 @@ const FileList = ({
sizeLabel={rowMeta?.sizeLabel ?? ""}
modifiedLabel={rowMeta?.modifiedLabel ?? ""}
selected={selectedPaths.has(row.entry.path)}
+ isDeleting={isDeletePending(row.entry.path)}
dropTarget={isDropTarget}
isRenaming={renameTargetPath === row.entry.path}
renameValue={renameValue}
diff --git a/src/components/explorer/FileView.tsx b/src/components/explorer/FileView.tsx
index 4847c75..2544a97 100644
--- a/src/components/explorer/FileView.tsx
+++ b/src/components/explorer/FileView.tsx
@@ -51,7 +51,7 @@ type FileViewProps = {
scrollRestoreTop: number;
scrollRequest?: { index: number; nonce: number } | null;
smoothScroll: boolean;
- compactMode: boolean;
+ pendingDeletePaths: Set;
sortState: SortState;
onSortChange: (next: SortState) => void;
selectedPaths: Set;
@@ -154,7 +154,7 @@ export const FileView = ({
onEntryPreviewPress,
onEntryPreviewRelease,
smoothScroll,
- compactMode,
+ pendingDeletePaths,
sortState,
onSortChange,
onContextMenuDown,
@@ -206,7 +206,7 @@ export const FileView = ({
{...viewProps}
currentPath={currentPath}
smoothScroll={smoothScroll}
- compactMode={compactMode}
+ pendingDeletePaths={pendingDeletePaths}
canGoUp={canGoUp}
onGoUp={onGoUp}
thumbnailsEnabled={thumbnailsEnabled}
@@ -251,7 +251,7 @@ export const FileView = ({
{...viewProps}
currentPath={currentPath}
smoothScroll={smoothScroll}
- compactMode={compactMode}
+ pendingDeletePaths={pendingDeletePaths}
sortState={sortState}
onSortChange={onSortChange}
categoryTinting={categoryTinting}
diff --git a/src/components/explorer/PathBar.tsx b/src/components/explorer/PathBar.tsx
index b624cea..50ed65a 100644
--- a/src/components/explorer/PathBar.tsx
+++ b/src/components/explorer/PathBar.tsx
@@ -1,9 +1,10 @@
-// Navigation controls row that sits above the inputs.
-import type { ReactNode } from "react";
+// Navigation controls row that sits above the inputs.
+import type { ReactNode } from "react";
import { PressButton } from "@/components/primitives/PressButton";
+import { WindowChromeBar } from "@/components/primitives/WindowChromeBar";
import { ChevronDownIcon, ChevronUpIcon, NavArrowIcon } from "@/components/icons";
-type PathBarProps = {
+type PathBarProps = {
onBack: () => void;
onForward: () => void;
onUp: () => void;
@@ -16,6 +17,7 @@ type PathBarProps = {
leftSlot?: ReactNode;
driveSlot?: ReactNode;
rightSlot?: ReactNode;
+ windowControlsSlot?: ReactNode;
};
export const PathBar = ({
@@ -31,11 +33,12 @@ export const PathBar = ({
leftSlot,
driveSlot,
rightSlot,
+ windowControlsSlot,
}: PathBarProps) => {
- return (
-
- {leftSlot ?
{leftSlot}
: null}
-
+ const leftContent = leftSlot ?
{leftSlot}
: null;
+ const centerContent = (
+ <>
+
- {driveSlot ?
{driveSlot}
: null}
- {rightSlot ?
{rightSlot}
: null}
-
- );
-};
+ {driveSlot ?
{driveSlot}
: null}
+ >
+ );
+ const rightContent = rightSlot ?
{rightSlot}
: null;
+
+ return (
+
+ );
+};
diff --git a/src/components/explorer/PathBarActions.tsx b/src/components/explorer/PathBarActions.tsx
index 34436ee..ad29bda 100644
--- a/src/components/explorer/PathBarActions.tsx
+++ b/src/components/explorer/PathBarActions.tsx
@@ -1,4 +1,5 @@
-// Action cluster aligned to the right side of the path bar.
+// Action cluster aligned to the right side of the path bar.
+import { PATHBAR_TOOLTIP_DELAY_MS } from "@/constants";
import { TransferStatusButton } from "@/components/transfer/TransferStatusButton";
import { ToolbarIconButton } from "@/components/primitives/ToolbarIconButton";
import { GridIcon, ListIcon, RecycleBinIcon, SettingsIcon } from "@/components/icons";
@@ -25,6 +26,7 @@ export const PathBarActions = ({
@@ -33,24 +35,27 @@ export const PathBarActions = ({
label="List view"
active={viewMode === "list"}
pressed={viewMode === "list"}
+ tooltipDelayMs={PATHBAR_TOOLTIP_DELAY_MS}
onClick={() => onViewChange("list")}
- >
+ >
- onViewChange("thumbs")}
- >
+ onViewChange("thumbs")}
+ >
-
+
diff --git a/src/components/explorer/fileGrid/EntryCard.tsx b/src/components/explorer/fileGrid/EntryCard.tsx
index 0ec2137..2dc4e0a 100644
--- a/src/components/explorer/fileGrid/EntryCard.tsx
+++ b/src/components/explorer/fileGrid/EntryCard.tsx
@@ -40,10 +40,11 @@ export const EntryCard = memo(({
disableTooltip = false,
showSize,
showExtension,
- nameEllipsis,
- hideExtension,
- selected,
- dropTarget,
+ nameEllipsis,
+ hideExtension,
+ selected,
+ isDeleting,
+ dropTarget,
isRenaming,
renameValue,
onRenameChange,
@@ -108,13 +109,16 @@ export const EntryCard = memo(({
]
: displayName;
const nameEllipsisMode = useMiddleEllipsis ? "middle" : "end";
- // Keep the card element stable so thumbnail previews do not remount on rename.
+ // Keep the card element stable so thumbnail previews do not remount on rename.
const isRemoved = presence === "removed";
- const isInteractive = !isRenaming && !isRemoved;
- const tooltipDisabled = isRenaming || entry.isDir || disableTooltip || !isInteractive;
+ const isInteractive = !isRenaming && !isRemoved && !isDeleting;
+ const tooltipDisabled =
+ isRenaming || isDeleting || entry.isDir || disableTooltip || !isInteractive;
const cardClass = `thumb-card${isRenaming ? " is-renaming" : ""}${
entry.isDir ? " is-dir" : ""
- }${selected ? " is-selected" : ""}${isRemoved ? " is-removed" : ""}`;
+ }${selected ? " is-selected" : ""}${isRemoved ? " is-removed" : ""}${
+ isDeleting ? " is-deleting" : ""
+ }`;
const handleMouseDown = (event: ReactMouseEvent) => {
if (!isInteractive) return;
if (event.button === 1) {
@@ -185,6 +189,7 @@ export const EntryCard = memo(({
data-is-dir={entry.isDir ? "true" : "false"}
data-kind={fileKind}
data-drop-target={dropTarget ? "true" : "false"}
+ data-delete-pending={isDeleting ? "true" : "false"}
data-presence={presence}
aria-selected={selected}
aria-hidden={isRemoved ? "true" : "false"}
diff --git a/src/components/explorer/fileGrid/gridCard.types.ts b/src/components/explorer/fileGrid/gridCard.types.ts
index aeb560c..34bbeb8 100644
--- a/src/components/explorer/fileGrid/gridCard.types.ts
+++ b/src/components/explorer/fileGrid/gridCard.types.ts
@@ -48,6 +48,7 @@ export type EntryCardProps = {
nameEllipsis: GridNameEllipsis;
hideExtension: boolean;
selected: boolean;
+ isDeleting: boolean;
dropTarget: boolean;
isRenaming: boolean;
renameValue: string;
diff --git a/src/components/explorer/fileGrid/useGridSelection.ts b/src/components/explorer/fileGrid/useGridSelection.ts
index 58fd9b8..fdc2ec4 100644
--- a/src/components/explorer/fileGrid/useGridSelection.ts
+++ b/src/components/explorer/fileGrid/useGridSelection.ts
@@ -2,7 +2,7 @@
import type { RefObject } from "react";
import { useEntryDragOut, useSelectionDrag } from "@/hooks";
import type { DropTarget } from "@/lib";
-import { COMPACT_VIEW_INSET } from "../fileView/constants";
+import { VIEW_INSET } from "../fileView/constants";
const noop = () => {};
@@ -14,7 +14,6 @@ type UseGridSelectionOptions = {
columnWidth: number;
rowHeight: number;
gridGap: number;
- compactMode: boolean;
onSetSelection: (paths: string[], anchor?: string) => void;
onClearSelection: () => void;
onStartDragOut?: (paths: string[]) => void;
@@ -31,7 +30,6 @@ export const useGridSelection = ({
columnWidth,
rowHeight,
gridGap,
- compactMode,
onSetSelection,
onClearSelection,
onStartDragOut,
@@ -51,7 +49,7 @@ export const useGridSelection = ({
columnWidth,
rowHeight,
gap: gridGap,
- insetTop: compactMode ? COMPACT_VIEW_INSET : 0,
+ insetTop: VIEW_INSET,
},
});
diff --git a/src/components/explorer/fileGrid/useGridVirtual.ts b/src/components/explorer/fileGrid/useGridVirtual.ts
index 3f16dd1..3cd37d0 100644
--- a/src/components/explorer/fileGrid/useGridVirtual.ts
+++ b/src/components/explorer/fileGrid/useGridVirtual.ts
@@ -3,7 +3,7 @@ import type { RefObject } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useDynamicOverscan, useVirtualRange } from "@/hooks";
import type { EntryItem } from "@/lib";
-import { COMPACT_VIEW_INSET } from "../fileView/constants";
+import { VIEW_INSET } from "../fileView/constants";
const GRID_OVERSCAN = 3;
const GRID_OVERSCAN_MIN = 1;
@@ -19,7 +19,6 @@ type UseGridVirtualOptions = {
columnCount: number;
rowCount: number;
rowHeight: number;
- compactMode: boolean;
viewItems: EntryItem[];
};
@@ -36,7 +35,6 @@ export const useGridVirtual = ({
columnCount,
rowCount,
rowHeight,
- compactMode,
viewItems,
}: UseGridVirtualOptions): GridVirtualState => {
const baseOverscan = useDynamicOverscan({
@@ -142,14 +140,13 @@ export const useGridVirtual = ({
GRID_OVERSCAN_BURST_MAX,
Math.max(GRID_OVERSCAN_MIN, baseOverscan + wheelBurstOverscan),
);
- const viewInset = compactMode ? COMPACT_VIEW_INSET : 0;
const virtual = useVirtualRange(
viewportRef,
rowCount,
rowHeight,
overscan,
- viewInset,
- viewInset,
+ VIEW_INSET,
+ VIEW_INSET,
);
const startIndex = virtual.startIndex * columnCount;
const endIndex = Math.min(viewItems.length, virtual.endIndex * columnCount);
diff --git a/src/components/explorer/fileList/EntryRow.tsx b/src/components/explorer/fileList/EntryRow.tsx
index fd79a49..e97f1b8 100644
--- a/src/components/explorer/fileList/EntryRow.tsx
+++ b/src/components/explorer/fileList/EntryRow.tsx
@@ -18,6 +18,7 @@ type EntryRowProps = {
sizeLabel: string;
modifiedLabel: string;
selected: boolean;
+ isDeleting: boolean;
dropTarget: boolean;
isRenaming: boolean;
renameValue: string;
@@ -42,6 +43,7 @@ export const EntryRow = memo(({
sizeLabel,
modifiedLabel,
selected,
+ isDeleting,
dropTarget,
isRenaming,
renameValue,
@@ -58,8 +60,8 @@ export const EntryRow = memo(({
presence = "stable",
}: EntryRowProps) => {
const isRemoved = presence === "removed";
- const isInteractive = !isRenaming && !isRemoved;
- const isSelectable = !isRemoved;
+ const isInteractive = !isRenaming && !isRemoved && !isDeleting;
+ const isSelectable = !isRemoved && !isDeleting;
const handleMouseDown = (event: ReactMouseEvent) => {
if (!isInteractive) return;
if (event.button === 1) {
@@ -120,7 +122,7 @@ export const EntryRow = memo(({
{
const listRef = useRef
(null);
- // Match virtualization height with compact spacing when enabled.
- const rowHeight = compactMode ? COMPACT_ROW_HEIGHT : ROW_HEIGHT;
- const rowGap = compactMode ? COMPACT_ROW_GAP : ROW_GAP;
+ const rowHeight = ROW_HEIGHT;
+ const rowGap = ROW_GAP;
const itemHeight = rowHeight + rowGap;
const listVars = useMemo(
diff --git a/src/components/explorer/fileList/useListSelection.ts b/src/components/explorer/fileList/useListSelection.ts
index 6350582..bc5e5e9 100644
--- a/src/components/explorer/fileList/useListSelection.ts
+++ b/src/components/explorer/fileList/useListSelection.ts
@@ -2,7 +2,7 @@
import type { RefObject } from "react";
import { useEntryDragOut, useSelectionDrag } from "@/hooks";
import type { DropTarget } from "@/lib";
-import { COMPACT_VIEW_INSET } from "../fileView/constants";
+import { VIEW_INSET } from "../fileView/constants";
const noop = () => {};
@@ -12,7 +12,6 @@ type UseListSelectionOptions = {
selectionItems: { path: string; selectable: boolean }[];
itemHeight: number;
rowHeight: number;
- compactMode: boolean;
onSetSelection: (paths: string[], anchor?: string) => void;
onClearSelection: () => void;
onStartDragOut?: (paths: string[]) => void;
@@ -27,7 +26,6 @@ export const useListSelection = ({
selectionItems,
itemHeight,
rowHeight,
- compactMode,
onSetSelection,
onClearSelection,
onStartDragOut,
@@ -45,7 +43,7 @@ export const useListSelection = ({
items: selectionItems,
itemHeight,
rowHeight,
- insetTop: compactMode ? COMPACT_VIEW_INSET : 0,
+ insetTop: VIEW_INSET,
},
});
diff --git a/src/components/explorer/fileList/useListVirtual.ts b/src/components/explorer/fileList/useListVirtual.ts
index 771d3c7..a2eb0dc 100644
--- a/src/components/explorer/fileList/useListVirtual.ts
+++ b/src/components/explorer/fileList/useListVirtual.ts
@@ -3,7 +3,7 @@ import type { RefObject } from "react";
import { useMemo } from "react";
import { useDynamicOverscan, useVirtualRange } from "@/hooks";
import type { EntryItem } from "@/lib";
-import { COMPACT_VIEW_INSET } from "../fileView/constants";
+import { VIEW_INSET } from "../fileView/constants";
const OVERSCAN = 10;
const OVERSCAN_MIN = 2;
@@ -13,7 +13,6 @@ type UseListVirtualOptions = {
listRef: RefObject;
viewKey: string;
itemHeight: number;
- compactMode: boolean;
rows: EntryItem[];
};
@@ -26,7 +25,6 @@ export const useListVirtual = ({
listRef,
viewKey,
itemHeight,
- compactMode,
rows,
}: UseListVirtualOptions): ListVirtualState => {
const overscan = useDynamicOverscan({
@@ -35,14 +33,13 @@ export const useListVirtual = ({
min: OVERSCAN_MIN,
warmupMs: OVERSCAN_WARMUP_MS,
});
- const viewInset = compactMode ? COMPACT_VIEW_INSET : 0;
const virtual = useVirtualRange(
listRef,
rows.length,
itemHeight,
overscan,
- viewInset,
- viewInset,
+ VIEW_INSET,
+ VIEW_INSET,
);
// Memoize the visible slice so selection drags don't rebuild row metadata.
const visibleRows = useMemo(
diff --git a/src/components/explorer/fileView/constants.ts b/src/components/explorer/fileView/constants.ts
index 631bfaf..1b8e36b 100644
--- a/src/components/explorer/fileView/constants.ts
+++ b/src/components/explorer/fileView/constants.ts
@@ -1,2 +1,2 @@
// Shared layout constants for list/grid virtualization and selection.
-export const COMPACT_VIEW_INSET = 10;
+export const VIEW_INSET = 10;
diff --git a/src/components/icons/uiIcons.tsx b/src/components/icons/uiIcons.tsx
index 1285ad3..f4fbaa6 100644
--- a/src/components/icons/uiIcons.tsx
+++ b/src/components/icons/uiIcons.tsx
@@ -352,6 +352,44 @@ export const TabCloseIcon = ({ className }: IconProps) => {
);
};
+// Window caption close icon based on the provided reference SVG.
+export const WindowCloseIcon = ({ className }: IconProps) => {
+ return (
+
+
+
+ );
+};
+
+// Window caption maximize icon based on the provided reference SVG.
+export const WindowMaximizeIcon = ({ className }: IconProps) => {
+ return (
+
+
+
+ );
+};
+
+// Window caption minimize icon based on the provided reference SVG.
+export const WindowMinimizeIcon = ({ className }: IconProps) => {
+ return (
+
+
+
+ );
+};
+
export const PlusIcon = ({ className }: IconProps) => {
return (
@@ -383,4 +421,25 @@ export const SettingsIcon = ({ className }: IconProps) => {
);
-};
+};
+
+// Inline Stratum brand mark used in the custom title bar.
+export const StratumBrandIcon = ({ className }: IconProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/components/navigation/WindowControls.tsx b/src/components/navigation/WindowControls.tsx
new file mode 100644
index 0000000..4e51513
--- /dev/null
+++ b/src/components/navigation/WindowControls.tsx
@@ -0,0 +1,58 @@
+// Right-aligned window caption controls for the custom path bar title area.
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import {
+ WindowCloseIcon,
+ WindowMaximizeIcon,
+ WindowMinimizeIcon,
+} from "@/components/icons";
+import { PressButton } from "@/components/primitives/PressButton";
+
+const isTauriEnv = () => {
+ return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
+};
+
+const runWindowAction = (action: "minimize" | "toggleMaximize" | "close") => {
+ if (!isTauriEnv()) return;
+ const appWindow = getCurrentWindow();
+ void appWindow[action]().catch(() => {
+ // Ignore window-control failures so the shell remains responsive.
+ });
+};
+
+export const WindowControls = () => {
+ if (!isTauriEnv()) {
+ return null;
+ }
+
+ return (
+
+
runWindowAction("minimize")}
+ aria-label="Minimize window"
+ >
+
+
+
runWindowAction("toggleMaximize")}
+ aria-label="Maximize or restore window"
+ >
+
+
+
runWindowAction("close")}
+ aria-label="Close window"
+ >
+
+
+
+ );
+};
diff --git a/src/components/navigation/index.ts b/src/components/navigation/index.ts
index 2b58470..2df3f55 100644
--- a/src/components/navigation/index.ts
+++ b/src/components/navigation/index.ts
@@ -5,4 +5,5 @@ export { Sidebar } from "./Sidebar";
export { SidebarSection } from "./SidebarSection";
export { StatusBar } from "./StatusBar";
export { TabsBar } from "./TabsBar";
-export { TopBar } from "./TopBar";
\ No newline at end of file
+export { TopBar } from "./TopBar";
+export { WindowControls } from "./WindowControls";
diff --git a/src/components/overlay/AboutModal.tsx b/src/components/overlay/AboutModal.tsx
index 887e071..4bb4b33 100644
--- a/src/components/overlay/AboutModal.tsx
+++ b/src/components/overlay/AboutModal.tsx
@@ -102,15 +102,10 @@ export const AboutModal = ({
onClick={(event) => event.stopPropagation()}
>
-
-
-
Compact mode
-
- Edge-to-edge layout with a flush sidebar and a simplified content frame.
-
-
-
- onUpdate({ compactMode: event.currentTarget.checked })}
- />
-
-
-
Parent directory entry
diff --git a/src/components/settings/SettingsViewSection.tsx b/src/components/settings/SettingsViewSection.tsx
deleted file mode 100644
index c559cb2..0000000
--- a/src/components/settings/SettingsViewSection.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-// Deprecated view settings section; superseded by SettingsGeneralSection.
-import type { ViewMode } from "@/types";
-import type { SettingsUpdateHandler } from "./types";
-import { PressButton } from "@/components/primitives/PressButton";
-
-type SettingsViewSectionProps = {
- sectionId: string;
- defaultViewMode: ViewMode;
- smoothScroll: boolean;
- gridCentered: boolean;
- compactMode: boolean;
- showParentEntry: boolean;
- confirmDelete: boolean;
- onUpdate: SettingsUpdateHandler;
-};
-
-export const SettingsViewSection = ({
- sectionId,
- defaultViewMode,
- smoothScroll,
- gridCentered,
- compactMode,
- showParentEntry,
- confirmDelete,
- onUpdate,
-}: SettingsViewSectionProps) => {
- return (
-
- View
-
-
-
Default view
-
Used for new tabs and new sessions.
-
-
-
onUpdate({ defaultViewMode: "thumbs" })}
- >
- Grid
-
-
onUpdate({ defaultViewMode: "list" })}
- >
- List
-
-
-
-
-
-
Smooth scrolling
-
- Keep the original smooth wheel scroll instead of snapping by row.
-
-
-
- onUpdate({ smoothScroll: event.currentTarget.checked })}
- />
-
-
-
-
-
-
Center grid items
-
Center the grid within the viewport.
-
-
- onUpdate({ gridCentered: event.currentTarget.checked })}
- />
-
-
-
-
-
-
Compact mode
-
- Edge-to-edge layout with a flush sidebar and a simplified content frame.
-
-
-
- onUpdate({ compactMode: event.currentTarget.checked })}
- />
-
-
-
-
-
-
Parent directory entry
-
Show a pseudo entry for moving up one level.
-
-
- onUpdate({ showParentEntry: event.currentTarget.checked })}
- />
-
-
-
-
-
-
Confirm deletes
-
- Ask before sending items to the trash.
-
-
-
- onUpdate({ confirmDelete: event.currentTarget.checked })}
- />
-
-
-
-
- );
-};
diff --git a/src/constants/index.ts b/src/constants/index.ts
index d8483c7..af6a930 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -3,6 +3,7 @@ export {
clampTooltipDelay,
DEFAULT_TOOLTIP_DELAY_MS,
FILE_TOOLTIP_DELAY_MS,
+ PATHBAR_TOOLTIP_DELAY_MS,
TOOLTIP_CACHE_LIMIT,
TOOLTIP_EDGE_PADDING,
TOOLTIP_GAP,
diff --git a/src/constants/settings.ts b/src/constants/settings.ts
index b904503..cf31ca2 100644
--- a/src/constants/settings.ts
+++ b/src/constants/settings.ts
@@ -10,4 +10,4 @@ export const GRID_GAP_MAX = 24;
export const GRID_GAP_DEFAULT = 8;
export const SETTINGS_STORAGE_KEY = "stratum.settings";
-export const SETTINGS_STORAGE_VERSION = 18;
+export const SETTINGS_STORAGE_VERSION = 19;
diff --git a/src/constants/tooltip.ts b/src/constants/tooltip.ts
index 71eb9d6..efae95f 100644
--- a/src/constants/tooltip.ts
+++ b/src/constants/tooltip.ts
@@ -1,5 +1,6 @@
// Tooltip timing and spacing defaults.
export const DEFAULT_TOOLTIP_DELAY_MS = 200;
+export const PATHBAR_TOOLTIP_DELAY_MS = DEFAULT_TOOLTIP_DELAY_MS * 2;
export const FILE_TOOLTIP_DELAY_MS = 1000;
export const TOOLTIP_EDGE_PADDING = 12;
export const TOOLTIP_GAP = 8;
diff --git a/src/hooks/domain/filesystem/useFileManager.ts b/src/hooks/domain/filesystem/useFileManager.ts
index 0e8fcb0..3052d01 100644
--- a/src/hooks/domain/filesystem/useFileManager.ts
+++ b/src/hooks/domain/filesystem/useFileManager.ts
@@ -92,6 +92,7 @@ export function useFileManager() {
entryMeta: metaCache.entryMeta,
loading: directoryLoader.loading,
suppressUndoPresence: fileMutations.suppressUndoPresence,
+ pendingDeletePaths: fileMutations.pendingDeletePaths,
status: directoryLoader.status,
loadDir: directoryLoader.loadDir,
clearDir: directoryLoader.clearDir,
diff --git a/src/hooks/domain/filesystem/useFileMutations.ts b/src/hooks/domain/filesystem/useFileMutations.ts
index b7223c9..7790c7c 100644
--- a/src/hooks/domain/filesystem/useFileMutations.ts
+++ b/src/hooks/domain/filesystem/useFileMutations.ts
@@ -3,7 +3,7 @@ import { useCallback, useRef, useState } from "react";
import type { RefObject } from "react";
import { renameEntry } from "@/api";
import { UNDO_STACK_LIMIT } from "@/constants";
-import { tabLabel, toMessage } from "@/lib";
+import { normalizePath, tabLabel, toMessage } from "@/lib";
import { usePromptStore } from "@/modules";
import { useFileManagerCopy } from "./fileManagerCopy";
import { useFileManagerCreate } from "./fileManagerCreate";
@@ -40,6 +40,14 @@ export const useFileMutations = ({
const homeDriveKeyRef = useRef
(null);
// Suppress add/remove presence animation while undoing rename so entries stay in-place.
const [suppressUndoPresence, setSuppressUndoPresence] = useState(false);
+ // Tracks entries currently being deleted so views can show immediate progress feedback.
+ const [pendingDeletePaths, setPendingDeletePaths] = useState>(new Set());
+
+ const toPathKey = useCallback((path: string) => {
+ const trimmed = path.trim();
+ if (!trimmed) return "";
+ return normalizePath(trimmed) ?? trimmed;
+ }, []);
const pushUndo = useCallback((action: UndoAction) => {
const stack = undoStackRef.current;
@@ -64,7 +72,7 @@ export const useFileMutations = ({
log,
});
- const { deleteEntriesInView } = useFileManagerDelete({
+ const { deleteEntriesInView: runDeleteEntriesInView } = useFileManagerDelete({
deleteInFlightRef,
trashRootRef,
homePathRef,
@@ -74,6 +82,37 @@ export const useFileMutations = ({
log,
});
+ const deleteEntriesInView = useCallback(
+ async (paths: string[]) => {
+ if (deleteInFlightRef.current) return null;
+ const nextPending = new Set(
+ paths
+ .map((path) => toPathKey(path))
+ .filter(Boolean),
+ );
+ if (nextPending.size === 0) return null;
+
+ setPendingDeletePaths((previous) => {
+ if (previous.size === 0) return new Set(nextPending);
+ const merged = new Set(previous);
+ nextPending.forEach((path) => merged.add(path));
+ return merged;
+ });
+
+ try {
+ return await runDeleteEntriesInView(paths);
+ } finally {
+ setPendingDeletePaths((previous) => {
+ if (previous.size === 0) return previous;
+ const next = new Set(previous);
+ nextPending.forEach((path) => next.delete(path));
+ return next;
+ });
+ }
+ },
+ [runDeleteEntriesInView, toPathKey],
+ );
+
const performRenameRequests = useCallback(
async (
renames: RenameRequest[],
@@ -172,6 +211,7 @@ export const useFileMutations = ({
return {
suppressUndoPresence,
+ pendingDeletePaths,
deleteEntriesInView,
duplicateEntriesInView,
pasteEntriesInView,
diff --git a/src/hooks/ui/app/useAppAppearance.ts b/src/hooks/ui/app/useAppAppearance.ts
index f4bf2b8..27d3458 100644
--- a/src/hooks/ui/app/useAppAppearance.ts
+++ b/src/hooks/ui/app/useAppAppearance.ts
@@ -2,30 +2,27 @@
import { useEffect } from "react";
import type { AccentTheme } from "@/modules";
-type AppAppearanceOptions = {
- accentTheme: AccentTheme;
- ambientBackground: boolean;
- blurOverlays: boolean;
- gridRounded: boolean;
- gridCentered: boolean;
- compactMode: boolean;
-};
-
-export const useAppAppearance = ({
- accentTheme,
- ambientBackground,
- blurOverlays,
- gridRounded,
- gridCentered,
- compactMode,
-}: AppAppearanceOptions) => {
- useEffect(() => {
- const root = document.documentElement;
- root.dataset.accent = accentTheme;
- root.dataset.ambient = ambientBackground ? "true" : "false";
- root.dataset.blurOverlays = blurOverlays ? "true" : "false";
- root.dataset.gridCorners = gridRounded ? "rounded" : "straight";
- root.dataset.gridCenter = gridCentered ? "true" : "false";
- root.dataset.compact = compactMode ? "true" : "false";
- }, [accentTheme, ambientBackground, blurOverlays, gridRounded, gridCentered, compactMode]);
-};
+type AppAppearanceOptions = {
+ accentTheme: AccentTheme;
+ ambientBackground: boolean;
+ blurOverlays: boolean;
+ gridRounded: boolean;
+ gridCentered: boolean;
+};
+
+export const useAppAppearance = ({
+ accentTheme,
+ ambientBackground,
+ blurOverlays,
+ gridRounded,
+ gridCentered,
+}: AppAppearanceOptions) => {
+ useEffect(() => {
+ const root = document.documentElement;
+ root.dataset.accent = accentTheme;
+ root.dataset.ambient = ambientBackground ? "true" : "false";
+ root.dataset.blurOverlays = blurOverlays ? "true" : "false";
+ root.dataset.gridCorners = gridRounded ? "rounded" : "straight";
+ root.dataset.gridCenter = gridCentered ? "true" : "false";
+ }, [accentTheme, ambientBackground, blurOverlays, gridRounded, gridCentered]);
+};
diff --git a/src/hooks/ui/app/useAppEffects.ts b/src/hooks/ui/app/useAppEffects.ts
index 1bc053f..7cc0c57 100644
--- a/src/hooks/ui/app/useAppEffects.ts
+++ b/src/hooks/ui/app/useAppEffects.ts
@@ -29,7 +29,6 @@ type AppEffectSettings = {
blurOverlays: boolean;
gridRounded: boolean;
gridCentered: boolean;
- compactMode: boolean;
};
type AppEffectView = {
@@ -101,7 +100,6 @@ export const useAppEffects = ({
blurOverlays,
gridRounded,
gridCentered,
- compactMode,
} = settings;
const {
activeTabId,
@@ -172,7 +170,6 @@ export const useAppEffects = ({
blurOverlays,
gridRounded,
gridCentered,
- compactMode,
});
useEffect(() => {
@@ -299,6 +296,22 @@ export const useAppEffects = ({
void appWindow.setTitle(title);
}, [appName, appVersion, isTauriEnv, viewPath]);
+ useEffect(() => {
+ const root = document.documentElement;
+ const syncWindowFocus = () => {
+ root.dataset.windowFocus = document.hasFocus() ? "true" : "false";
+ };
+
+ syncWindowFocus();
+ window.addEventListener("focus", syncWindowFocus);
+ window.addEventListener("blur", syncWindowFocus);
+
+ return () => {
+ window.removeEventListener("focus", syncWindowFocus);
+ window.removeEventListener("blur", syncWindowFocus);
+ };
+ }, []);
+
useEffect(() => {
const handleBeforeUnload = () => {
stashActiveScroll();
diff --git a/src/hooks/ui/app/useAppFileViewProps.tsx b/src/hooks/ui/app/useAppFileViewProps.tsx
index d9a7624..1c2ca32 100644
--- a/src/hooks/ui/app/useAppFileViewProps.tsx
+++ b/src/hooks/ui/app/useAppFileViewProps.tsx
@@ -20,7 +20,7 @@ type UseAppFileViewPropsOptions = {
| "scrollRestoreTop"
| "scrollRequest"
| "smoothScroll"
- | "compactMode"
+ | "pendingDeletePaths"
| "sortState"
| "canGoUp"
>;
diff --git a/src/hooks/ui/app/useAppTopstackProps.tsx b/src/hooks/ui/app/useAppTopstackProps.tsx
index a91ebac..5a07e42 100644
--- a/src/hooks/ui/app/useAppTopstackProps.tsx
+++ b/src/hooks/ui/app/useAppTopstackProps.tsx
@@ -1,11 +1,14 @@
// Builds the topstack props so App.tsx stays focused on data flow.
import type { ComponentProps, RefObject } from "react";
+import { PATHBAR_TOOLTIP_DELAY_MS } from "@/constants";
import {
DrivePicker,
PathBarActions,
PressButton,
SidebarIcon,
+ StratumBrandIcon,
ToolbarIconButton,
+ WindowControls,
} from "@/components";
import type { AppTopstackContainerProps } from "@/components/app/AppTopstackContainer";
import { PathBar } from "@/components/explorer/PathBar";
@@ -16,7 +19,10 @@ import { TabsBar } from "@/components/navigation/TabsBar";
type UseAppTopstackPropsOptions = {
appName: string;
topstackRef: RefObject;
- pathBar: Omit, "leftSlot" | "driveSlot" | "rightSlot">;
+ pathBar: Omit<
+ ComponentProps,
+ "leftSlot" | "driveSlot" | "rightSlot" | "windowControlsSlot"
+ >;
pathInputsBar: ComponentProps;
tabsBar: ComponentProps;
crumbsBar: ComponentProps;
@@ -50,13 +56,14 @@ export const useAppTopstackProps = ({
onClick={onOpenAbout}
>
-
+
@@ -71,6 +78,7 @@ export const useAppTopstackProps = ({
leftSlot,
driveSlot: ,
rightSlot: ,
+ windowControlsSlot: ,
},
pathInputsBar,
tabsBar,
diff --git a/src/modules/settings.defaults.ts b/src/modules/settings.defaults.ts
index 484d913..100ece7 100644
--- a/src/modules/settings.defaults.ts
+++ b/src/modules/settings.defaults.ts
@@ -27,7 +27,6 @@ export const DEFAULT_SETTINGS: Settings = {
fixedWidthTabs: false,
smoothScroll: false,
smartTabJump: true,
- compactMode: true,
accentTheme: "red",
categoryTinting: false,
showParentEntry: true,
diff --git a/src/modules/settings.normalize.ts b/src/modules/settings.normalize.ts
index b44bc39..eee7bbd 100644
--- a/src/modules/settings.normalize.ts
+++ b/src/modules/settings.normalize.ts
@@ -150,10 +150,6 @@ export const coerceSettings = (value: Partial | null | undefined): Set
typeof value?.smartTabJump === "boolean"
? value.smartTabJump
: DEFAULT_SETTINGS.smartTabJump,
- compactMode:
- typeof value?.compactMode === "boolean"
- ? value.compactMode
- : DEFAULT_SETTINGS.compactMode,
accentTheme: coerceAccentTheme(value?.accentTheme),
categoryTinting:
typeof value?.categoryTinting === "boolean"
diff --git a/src/modules/settings.store.ts b/src/modules/settings.store.ts
index 5bc52d3..cf5bc8d 100644
--- a/src/modules/settings.store.ts
+++ b/src/modules/settings.store.ts
@@ -76,7 +76,6 @@ useSettingsStore.subscribe((state) => {
fixedWidthTabs: state.fixedWidthTabs,
smoothScroll: state.smoothScroll,
smartTabJump: state.smartTabJump,
- compactMode: state.compactMode,
accentTheme: state.accentTheme,
categoryTinting: state.categoryTinting,
showParentEntry: state.showParentEntry,
diff --git a/src/modules/settings.types.ts b/src/modules/settings.types.ts
index 0445734..9b6e241 100644
--- a/src/modules/settings.types.ts
+++ b/src/modules/settings.types.ts
@@ -26,7 +26,6 @@ export type Settings = {
fixedWidthTabs: boolean;
smoothScroll: boolean;
smartTabJump: boolean;
- compactMode: boolean;
accentTheme: AccentTheme;
categoryTinting: boolean;
showParentEntry: boolean;
diff --git a/src/styles/app.scss b/src/styles/app.scss
index e3acf63..c5ac58f 100644
--- a/src/styles/app.scss
+++ b/src/styles/app.scss
@@ -10,6 +10,8 @@
@use "./layout/layout";
@use "./components/topstack";
+@use "./components/window-chrome";
+@use "./components/window-frame";
@use "./components/pathbar";
@use "./components/tabs";
@use "./components/crumbbar";
diff --git a/src/styles/base/_scrollbars.scss b/src/styles/base/_scrollbars.scss
index 6362dbc..cc61db5 100644
--- a/src/styles/base/_scrollbars.scss
+++ b/src/styles/base/_scrollbars.scss
@@ -1,10 +1,5 @@
// Shared scrollbar styling.
:root {
- --scrollbar-thumb: #20283a;
-}
-
-:root[data-compact="true"] {
- // Lighter thumb helps the scrollbar stay visible in dense layouts.
--scrollbar-thumb: #d9deea;
}
.list-body::-webkit-scrollbar {
diff --git a/src/styles/components/_file-view.scss b/src/styles/components/_file-view.scss
index 4152afc..4b05758 100644
--- a/src/styles/components/_file-view.scss
+++ b/src/styles/components/_file-view.scss
@@ -5,13 +5,8 @@
gap: 12px;
min-height: 0;
flex: 1;
- padding-top: 10px;
- --list-columns: 1fr 110px 180px;
-}
-
-:root[data-compact="true"] :where(.file-list) {
+ padding: var(--content-edge-gap);
--list-columns: 1fr 90px 150px;
- padding: var(--compact-edge-gap);
}
// Scope list internals to the list container.
@@ -56,12 +51,8 @@
overflow-y: auto;
min-height: 0;
flex: 1;
- padding: var(--thumb-padding, 6px);
- // Compensate for the reserved scrollbar gutter so side + top gaps stay even.
- padding-right: max(
- calc(var(--thumb-padding, 6px) - var(--thumb-scrollbar-size, 10px)),
- 0px
- );
+ padding: 0;
+ padding-right: 0;
scroll-behavior: auto;
scrollbar-gutter: stable;
scrollbar-width: auto;
@@ -104,10 +95,6 @@
}
}
-:root[data-compact="true"] :where(.thumb-shell) .thumb-viewport {
- padding: 0;
-}
-
:root[data-grid-center="false"] :where(.thumb-shell) .thumb-grid {
align-content: start;
justify-content: start;
@@ -157,6 +144,12 @@
place-items: center;
}
+@keyframes delete-pending-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.thumb-shell {
position: relative;
min-height: 0;
@@ -164,14 +157,12 @@
display: flex;
flex-direction: column;
overflow: hidden;
-}
-
-:root[data-compact="true"] :where(.thumb-shell) {
- padding: var(--compact-edge-gap);
+ padding: var(--content-edge-gap);
}
:where(.thumb-shell) {
.thumb-card {
+ position: relative;
border: 1px solid var(--stroke);
border-radius: 6px;
padding: 6px;
@@ -192,6 +183,50 @@
cursor: default;
}
+ // Show immediate in-card delete progress without shifting layout.
+ .thumb-card.is-deleting {
+ cursor: progress;
+ }
+
+ .thumb-card.is-deleting::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: rgba(7, 10, 16, 0.28);
+ pointer-events: none;
+ z-index: 3;
+ }
+
+ .thumb-card.is-deleting::after {
+ content: "";
+ position: absolute;
+ right: 8px;
+ top: 8px;
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(255, 255, 255, 0.34);
+ border-top-color: rgba(255, 255, 255, 0.95);
+ border-radius: 999px;
+ pointer-events: none;
+ z-index: 4;
+ animation: delete-pending-spin 0.8s linear infinite;
+ }
+
+ .thumb-card.is-deleting .thumb-icon {
+ filter: saturate(0.72) brightness(0.82);
+ }
+
+ .thumb-card.is-deleting .thumb-preview,
+ .thumb-card.is-deleting .thumb-app-icon,
+ .thumb-card.is-deleting .thumb-svg {
+ filter: blur(0.9px);
+ }
+
+ .thumb-card.is-deleting .thumb-meta {
+ opacity: 0.74;
+ }
+
// Give folders a subtle cool tint so they are easier to distinguish from files.
.thumb-card.is-dir:not(.is-selected):not([data-drop-target="true"]) {
background: rgba(92, 126, 196, 0.12);
@@ -414,7 +449,7 @@
color: rgba(220, 227, 238, 0.82);
}
-:root[data-ambient="true"][data-compact="true"] :where(.thumb-shell) .thumb-card.is-selected {
+:root[data-ambient="true"] :where(.thumb-shell) .thumb-card.is-selected {
border-color: rgba(var(--accent-rgb), 0.7);
background: rgba(var(--accent-rgb), 0.12);
}
@@ -426,9 +461,7 @@
background: rgba(var(--accent-rgb), 0.18);
}
-:root[data-ambient="true"][data-compact="true"]
- :where(.thumb-shell)
- .thumb-card[data-drop-target="true"] {
+:root[data-ambient="true"] :where(.thumb-shell) .thumb-card[data-drop-target="true"] {
border-color: rgba(var(--accent-rgb), 0.9);
background: rgba(var(--accent-rgb), 0.18);
}
@@ -527,7 +560,7 @@
display: grid;
grid-template-columns: var(--list-columns);
align-items: center;
- padding: 6px 10px;
+ padding: 4px 8px;
border-bottom: 1px solid var(--stroke);
background: rgba(12, 16, 24, 0.6);
}
@@ -538,14 +571,14 @@
justify-content: flex-start;
gap: 6px;
width: 100%;
- padding: 4px 6px;
+ padding: 3px 4px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
- font-size: 11px;
+ font-size: 10px;
font-weight: 600;
text-transform: uppercase;
- letter-spacing: 0.16em;
+ letter-spacing: 0.12em;
color: var(--muted);
text-align: left;
transition: none;
@@ -585,22 +618,12 @@
}
}
-:root[data-compact="true"] :where(.file-list) .list-header {
- padding: 4px 8px;
-}
-
-:root[data-compact="true"] :where(.file-list) .list-header-button {
- padding: 3px 4px;
- font-size: 10px;
- letter-spacing: 0.12em;
-}
-
:where(.file-list) {
.list-body {
position: relative;
overflow-x: hidden;
overflow-y: auto;
- padding-right: 8px;
+ padding-right: 0;
min-width: 0;
flex: 1;
scroll-behavior: auto;
@@ -675,10 +698,6 @@
}
}
-:root[data-compact="true"] :where(.file-list) .list-body {
- padding-right: 0;
-}
-
.selection-rect {
position: fixed;
z-index: 3;
@@ -711,17 +730,23 @@
transition: none;
opacity: 0.4;
}
+
+ :where(.file-list) .row.is-deleting::after,
+ :where(.thumb-shell) .thumb-card.is-deleting::after {
+ animation: none;
+ }
}
:where(.file-list) {
.row {
+ position: relative;
display: grid;
grid-template-columns: var(--list-columns);
align-items: center;
- gap: 12px;
- padding: 10px 14px;
+ gap: 8px;
+ padding: 6px 10px;
height: var(--list-row-height, auto);
- border-radius: 8px;
+ border-radius: 6px;
background: var(--surface-control-muted);
--list-dot-color: var(--accent);
border: 1px solid transparent;
@@ -734,9 +759,48 @@
cursor: default;
}
+ // Keep rows visible while delete work is in-flight, but mark them as pending.
+ .row.is-deleting {
+ cursor: progress;
+ }
+
+ .row.is-deleting::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: rgba(7, 10, 16, 0.26);
+ pointer-events: none;
+ z-index: 1;
+ }
+
+ .row.is-deleting::after {
+ content: "";
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ width: 11px;
+ height: 11px;
+ margin-top: -5.5px;
+ border: 2px solid rgba(255, 255, 255, 0.34);
+ border-top-color: rgba(255, 255, 255, 0.95);
+ border-radius: 999px;
+ pointer-events: none;
+ z-index: 2;
+ animation: delete-pending-spin 0.8s linear infinite;
+ }
+
+ .row.is-deleting .name,
+ .row.is-deleting .size,
+ .row.is-deleting .modified {
+ opacity: 0.72;
+ position: relative;
+ z-index: 2;
+ }
+
.row.is-renaming .name {
overflow: visible;
- gap: 8px;
+ gap: 6px;
}
.row.is-parent {
@@ -784,7 +848,8 @@
.row .name {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
+ font-size: 13px;
font-weight: 600;
min-width: 0;
overflow: hidden;
@@ -794,8 +859,8 @@
.row .name::before {
content: "";
- width: 10px;
- height: 10px;
+ width: 8px;
+ height: 8px;
border-radius: 999px;
background: var(--list-dot-color);
}
@@ -809,27 +874,6 @@
}
}
-:root[data-compact="true"] :where(.file-list) .row {
- gap: 8px;
- padding: 6px 10px;
- border-radius: 6px;
-}
-
-
-:root[data-compact="true"] :where(.file-list) .row .name {
- gap: 8px;
- font-size: 13px;
-}
-
-:root[data-compact="true"] :where(.file-list) .row.is-renaming .name {
- gap: 6px;
-}
-
-:root[data-compact="true"] :where(.file-list) .row .name::before {
- width: 8px;
- height: 8px;
-}
-
:root[data-ambient="true"] :where(.file-list) .row {
background: rgba(18, 22, 33, 0.82);
}
diff --git a/src/styles/components/_modals.scss b/src/styles/components/_modals.scss
index 32da65c..4c7a5c2 100644
--- a/src/styles/components/_modals.scss
+++ b/src/styles/components/_modals.scss
@@ -139,37 +139,13 @@
opacity: 1;
}
-:where(.about-panel) {
- .about-header {
- display: flex;
- gap: 14px;
- align-items: center;
- }
-
- .about-mark {
- width: 32px;
- height: 32px;
- border-radius: 6px;
- border: 1px solid var(--stroke);
- display: grid;
- place-items: center;
- background: rgba(255, 255, 255, 0.02);
- overflow: hidden;
-
- img {
- width: 100%;
- height: 100%;
- object-fit: contain;
- }
- }
-
- .about-upper {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
-
- .about-title {
+:where(.about-panel) {
+ .about-header {
+ display: grid;
+ gap: 4px;
+ }
+
+ .about-title {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
diff --git a/src/styles/components/_pathbar.scss b/src/styles/components/_pathbar.scss
index a9e5970..f6ffd11 100644
--- a/src/styles/components/_pathbar.scss
+++ b/src/styles/components/_pathbar.scss
@@ -1,12 +1,21 @@
-// Path bar, drive picker, and path/search inputs.
+// Path bar shell, drive picker, and path/search inputs.
+.pathbar-shell {
+ --pathbar-hover-surface: rgba(255, 255, 255, 0.06);
+ background: var(--panel-2);
+ border-bottom: 2px solid #1b213061;
+}
+
+:root[data-window-focus="false"] .pathbar-shell {
+ background: color-mix(in srgb, var(--panel-2) 97%, white);
+}
+
+:root[data-window-focus="false"] .pathbar-shell .drive-toggle-body {
+ background: color-mix(in srgb, var(--surface-control) 96%, white);
+}
+
.pathbar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 10px;
- background: var(--panel-2);
- border-bottom: 1px solid var(--stroke);
- width: 100%;
+ gap: 6px;
+ background: transparent;
}
// Scope path bar internals to the bar without increasing specificity.
@@ -15,7 +24,7 @@
.pathbar-right {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 6px;
flex: 0 0 auto;
}
@@ -24,6 +33,8 @@
align-items: center;
min-width: 0;
flex: 1 1 auto;
+ // Let the drag host receive pointer events anywhere the picker has no live controls.
+ pointer-events: none;
}
.pathbar-right {
@@ -31,8 +42,11 @@
}
.pathbar-brand {
- display: flex;
- align-items: center;
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ flex: 0 0 auto;
background: none;
border: none;
padding: 0;
@@ -41,19 +55,20 @@
}
.brand-mark {
- width: 30px;
- height: 30px;
- border-radius: 6px;
+ width: 22px;
+ height: 22px;
display: grid;
place-items: center;
- border: 1px solid var(--stroke);
- background: var(--surface-control);
- overflow: hidden;
+ border: none;
+ border-radius: 0;
+ background: none;
+ box-shadow: none;
+ color: var(--accent);
+ overflow: visible;
- img {
+ .brand-mark-icon {
width: 100%;
height: 100%;
- object-fit: contain;
display: block;
}
}
@@ -64,17 +79,19 @@
border-radius: 6px;
}
- .pathbar-brand:hover .brand-mark {
- filter: brightness(1.08) saturate(1.08);
+ .pathbar-brand:hover {
+ background: var(--pathbar-hover-surface);
+ border-radius: 5px;
}
.drive-picker {
position: relative;
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
min-width: 0;
flex: 1 1 auto;
+ pointer-events: none;
}
.drive-toggle {
@@ -82,30 +99,35 @@
align-items: center;
min-width: 0;
flex: 1 1 auto;
+ // Let unused width fall through to the path bar drag region.
+ pointer-events: none;
}
.drive-toggle-body {
display: inline-flex;
align-items: center;
- gap: 2px;
+ gap: 0;
min-width: 0;
max-width: 100%;
- height: 28px;
- border-radius: 6px;
+ height: 26px;
+ border-radius: 5px;
background: var(--surface-control);
- padding: 0 2px;
+ padding: 0;
overflow: hidden;
+ // Keep the picker chrome itself transparent to dragging.
+ // Only the real controls inside should capture input.
+ pointer-events: none;
}
.drive-picker-button {
display: inline-flex;
align-items: center;
gap: 6px;
- padding: 0 10px;
- border-radius: 4px;
+ padding: 0 9px;
+ border-radius: 3px;
border: 1px solid transparent;
background: transparent;
- font-size: 11px;
+ font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--muted);
@@ -114,10 +136,13 @@
white-space: nowrap;
height: 100%;
flex: 0 0 auto;
+ align-self: stretch;
+ min-height: 100%;
+ pointer-events: auto;
}
.drive-picker-button:hover {
- color: var(--text);
+ background: var(--pathbar-hover-surface);
}
.drive-picker-button:disabled {
@@ -146,6 +171,10 @@
transform: translateX(0);
}
+ .drive-picker[data-expanded="true"] .drive-picker-button {
+ border-radius: 3px 0 0 3px;
+ }
+
.drive-picker-scroll {
position: relative;
display: flex;
@@ -155,6 +184,12 @@
height: 100%;
}
+ .drive-picker[data-expanded="true"] .drive-picker-scroll {
+ margin-left: 2px;
+ padding-left: 6px;
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
+ }
+
.drive-picker-scroll::before,
.drive-picker-scroll::after {
content: "";
@@ -203,7 +238,7 @@
}
.drive-picker-item:hover {
- background-color: rgba(255, 255, 255, 0.2);
+ background-color: var(--pathbar-hover-surface);
}
.drive-picker-item.is-active {
@@ -263,14 +298,67 @@
.pathbar-actions {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
+
+ .view-btn:hover {
+ background: var(--pathbar-hover-surface);
+ }
}
.path-controls {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 4px;
flex: 0 0 auto;
+
+ > .btn {
+ border: none;
+ }
+
+ > .btn:hover:not(:disabled) {
+ background: var(--pathbar-hover-surface);
+ }
+ }
+
+ .transfer-button {
+ height: 26px;
+ padding: 0 8px;
+ gap: 5px;
+ }
+
+ .transfer-label {
+ font-size: 10px;
+ }
+
+ .transfer-count {
+ font-size: 9px;
+ }
+
+ .transfer-indicator {
+ width: 12px;
+ height: 12px;
+ }
+
+ .view-btn {
+ width: 26px;
+ height: 26px;
+ border-radius: 5px;
+ }
+
+ .view-btn svg {
+ width: 14px;
+ height: 14px;
+ }
+
+ .btn {
+ min-height: 26px;
+ padding: 2px 8px;
+ border-radius: 5px;
+ }
+
+ .btn-icon {
+ width: 14px;
+ height: 14px;
}
.nav-arrow {
@@ -281,14 +369,18 @@
transform: rotate(180deg);
}
}
-
-.path-tabs {
- display: flex;
- flex-direction: column;
- background: var(--panel);
- border-bottom: 1px solid var(--stroke);
-}
-
+
+.pathbar-window-controls {
+ min-height: var(--window-chrome-bar-height);
+}
+
+.path-tabs {
+ display: flex;
+ flex-direction: column;
+ background: var(--panel);
+ border-bottom: 2px solid #1b213061;
+}
+
.path-inputbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(160px, 220px);
@@ -376,34 +468,33 @@
flex: 0 0 auto;
}
}
-
-.btn {
- padding: 4px 10px;
- border-radius: 4px;
- border: 1px solid var(--stroke);
- background: var(--surface-control);
- font-weight: 600;
- font-size: 12px;
- cursor: pointer;
- transition: none;
-}
-
-.btn:hover {
- border-color: rgba(255, 255, 255, 0.2);
-}
-
-.btn:disabled {
- cursor: not-allowed;
- opacity: 0.5;
-}
-
-.btn.ghost {
- background: transparent;
-}
-
-.btn-icon {
- width: 16px;
- height: 16px;
- display: block;
-}
-
+
+.btn {
+ padding: 4px 10px;
+ border-radius: 4px;
+ border: 1px solid var(--stroke);
+ background: var(--surface-control);
+ font-weight: 600;
+ font-size: 12px;
+ cursor: pointer;
+ transition: none;
+}
+
+.btn:hover {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.btn.ghost {
+ background: transparent;
+}
+
+.btn-icon {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
diff --git a/src/styles/components/_quick-preview.scss b/src/styles/components/_quick-preview.scss
index 4bc577a..7414f08 100644
--- a/src/styles/components/_quick-preview.scss
+++ b/src/styles/components/_quick-preview.scss
@@ -30,13 +30,88 @@
z-index: 2450;
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
- grid-template-rows: minmax(0, 1fr) auto auto;
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
background: rgba(4, 6, 10, 0.64);
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
}
+.quick-preview-topbar-shell {
+ grid-column: 1 / -1;
+ grid-row: 1;
+ background: linear-gradient(180deg, rgba(8, 10, 16, 0.82), rgba(8, 10, 16, 0.58));
+ border-bottom: 2px solid #1b213061;
+ backdrop-filter: blur(2px) saturate(1.05);
+ -webkit-backdrop-filter: blur(2px) saturate(1.05);
+}
+
+:root[data-window-focus="false"] .quick-preview-topbar-shell {
+ background: linear-gradient(180deg, rgba(12, 15, 22, 0.83), rgba(12, 15, 22, 0.6));
+}
+
+.quick-preview-topbar-window-controls {
+ min-height: var(--window-chrome-bar-height);
+}
+
+.quick-preview-topbar {
+ gap: 8px;
+}
+
+.quick-preview-topbar-nav {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex: 0 0 auto;
+}
+
+.quick-preview-topbar-nav > .btn {
+ border: none;
+}
+
+.quick-preview-topbar-nav > .btn:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.quick-preview-topbar-nav .btn-icon {
+ width: 14px;
+ height: 14px;
+}
+
+.quick-preview-topbar-nav .nav-arrow {
+ transform-origin: center;
+}
+
+.quick-preview-topbar-nav .nav-arrow.is-back {
+ transform: rotate(180deg);
+}
+
+.quick-preview-topbar-title {
+ min-width: 0;
+ flex: 1 1 auto;
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--text);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ pointer-events: none;
+}
+
+.quick-preview-topbar-position {
+ flex: 0 0 auto;
+ min-width: 44px;
+ text-align: right;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ pointer-events: none;
+}
+
:root[data-ambient="true"] .quick-preview {
background: rgba(3, 4, 8, 0.76);
}
@@ -70,7 +145,7 @@
position: relative;
min-height: 0;
grid-column: 1;
- grid-row: 1;
+ grid-row: 2;
display: flex;
align-items: center;
justify-content: center;
@@ -200,7 +275,7 @@
padding: 8px 16px;
// Keep the info bar in the main column so it doesn't overlap the right strip.
grid-column: 1 / 2;
- grid-row: 3;
+ grid-row: 4;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(8, 10, 16, 0.6);
backdrop-filter: blur(2px) saturate(1.05);
@@ -225,7 +300,7 @@
position: relative;
// Keep video controls in the main column so they don't overlay the right strip.
grid-column: 1 / 2;
- grid-row: 2;
+ grid-row: 3;
display: flex;
align-items: center;
gap: 8px;
@@ -505,39 +580,6 @@
0 5px 11px rgba(0, 0, 0, 0.35);
}
-// Title fades out after a moment so the media remains the focus.
-.quick-preview-title {
- position: absolute;
- top: 12px;
- left: 12px;
- max-width: min(55vw, 560px);
- padding: 4px 8px;
- border-radius: 2px;
- border: 1px solid rgba(255, 255, 255, 0.12);
- background: rgba(8, 10, 16, 0.6);
- backdrop-filter: blur(2px) saturate(1.05);
- -webkit-backdrop-filter: blur(2px) saturate(1.05);
- color: var(--text);
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.03em;
- word-break: break-word;
- pointer-events: none;
- opacity: 1;
- animation: quick-preview-title-fade 2.4s ease 2.4s forwards;
- transition: opacity 0.2s ease;
-}
-
-@keyframes quick-preview-title-fade {
- 0%,
- 35% {
- opacity: 1;
- }
- 100% {
- opacity: 0;
- }
-}
-
.quick-preview-info-error {
font-size: 11px;
letter-spacing: 0.08em;
@@ -570,7 +612,7 @@
.quick-preview-strip {
position: relative;
grid-column: 2;
- grid-row: 1 / -1;
+ grid-row: 2 / -1;
overflow-x: hidden;
overflow-y: auto;
min-width: 0;
diff --git a/src/styles/components/_sidebar.scss b/src/styles/components/_sidebar.scss
index b911da6..80ee11a 100644
--- a/src/styles/components/_sidebar.scss
+++ b/src/styles/components/_sidebar.scss
@@ -6,6 +6,7 @@
--scrollbar-thumb: #1f2736;
background: #0d0f14;
border: 1px solid #1a2233;
+ border-left: none;
border-radius: var(--panel-radius);
padding: var(--sidebar-padding);
display: flex;
@@ -25,9 +26,11 @@
flex-direction: column;
gap: 12px;
min-height: 0;
- overflow: auto;
+ overflow-x: hidden;
+ overflow-y: scroll;
// Reserve a stable scrollbar gutter and align the scrollbar to the panel edge.
scrollbar-gutter: stable;
+ scrollbar-color: var(--scrollbar-thumb) transparent;
margin-right: calc(var(--sidebar-padding) * -1 + var(--sidebar-scrollbar-gap));
padding-right: calc(var(--sidebar-padding) + 4px - var(--sidebar-scrollbar-gap));
flex: 1;
@@ -254,8 +257,3 @@
color: rgba(var(--accent-rgb), 0.95);
}
-// Compact mode removes the outer container frame while keeping the content intact.
-:root[data-compact="true"] .sidebar {
- border-left: none;
-}
-
diff --git a/src/styles/components/_tabs.scss b/src/styles/components/_tabs.scss
index f802b60..6a21e2b 100644
--- a/src/styles/components/_tabs.scss
+++ b/src/styles/components/_tabs.scss
@@ -33,7 +33,12 @@
transition: none;
}
-.tabs-scroll-wrap[data-overflowed="false"] .tab-scroll-button {
+.tabs-scroll-wrap[data-overflowed="false"] .tab-scroll-button.is-left {
+ opacity: 0.24;
+ pointer-events: none;
+}
+
+.tabs-scroll-wrap[data-overflowed="false"] .tab-scroll-button.is-right {
opacity: 0;
pointer-events: none;
}
diff --git a/src/styles/components/_topstack.scss b/src/styles/components/_topstack.scss
index ad900b5..f958669 100644
--- a/src/styles/components/_topstack.scss
+++ b/src/styles/components/_topstack.scss
@@ -329,7 +329,6 @@
width: 28px;
height: 28px;
border-radius: 4px;
- background: var(--surface-control);
display: grid;
place-items: center;
cursor: pointer;
@@ -346,8 +345,7 @@
}
.view-btn:hover {
- border-color: rgba(255, 255, 255, 0.2);
- color: var(--text);
+ background: rgba(255, 255, 255, 0.06);
}
.view-btn.is-active {
diff --git a/src/styles/components/_window-chrome.scss b/src/styles/components/_window-chrome.scss
new file mode 100644
index 0000000..a94891b
--- /dev/null
+++ b/src/styles/components/_window-chrome.scss
@@ -0,0 +1,75 @@
+// Shared sizing for app-owned title bars so every window chrome row stays aligned.
+.window-chrome-bar {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ min-width: 0;
+ min-height: var(--window-chrome-bar-height);
+ padding: 4px 2px 2px 2px;
+}
+
+.window-chrome-bar-drag {
+ display: flex;
+ align-items: center;
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: var(--window-chrome-bar-height);
+ padding: 4px 8px;
+}
+
+.window-chrome-bar-controls {
+ display: flex;
+ align-items: stretch;
+ flex: 0 0 auto;
+ align-self: stretch;
+}
+
+.window-controls {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ flex: 0 0 auto;
+ align-self: stretch;
+}
+
+.window-control-button {
+ width: 40px;
+ min-height: var(--window-chrome-bar-height);
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--muted);
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ transition: none;
+ padding: 0;
+ align-self: stretch;
+}
+
+.window-control-button:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+}
+
+.window-control-button.is-close:hover {
+ background: rgba(225, 76, 76, 0.22);
+ color: #fff;
+}
+
+.window-control-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.window-control-icon {
+ width: 12px;
+ height: 12px;
+ display: block;
+ flex: 0 0 auto;
+}
+
+.window-control-icon.is-minimize {
+ width: 10px;
+ height: 10px;
+}
diff --git a/src/styles/components/_window-frame.scss b/src/styles/components/_window-frame.scss
new file mode 100644
index 0000000..fadf2a4
--- /dev/null
+++ b/src/styles/components/_window-frame.scss
@@ -0,0 +1,11 @@
+// Global frame accents that should remain visible above fullscreen overlays.
+.app-window-frame {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ pointer-events: none;
+ z-index: 2500;
+ background: var(--window-frame-edge);
+}
diff --git a/src/styles/layout/_app-shell.scss b/src/styles/layout/_app-shell.scss
index c439714..de2933d 100644
--- a/src/styles/layout/_app-shell.scss
+++ b/src/styles/layout/_app-shell.scss
@@ -3,10 +3,10 @@
min-height: 100vh;
height: 100vh;
display: flex;
- flex-direction: column;
- overflow: hidden;
- padding-top: var(--topstack-height);
- padding-bottom: var(--statusbar-height);
+ flex-direction: column;
+ overflow: hidden;
+ padding-top: var(--topstack-height);
+ padding-bottom: var(--statusbar-height);
position: relative;
isolation: isolate;
}
diff --git a/src/styles/layout/_layout.scss b/src/styles/layout/_layout.scss
index d064420..11c9f31 100644
--- a/src/styles/layout/_layout.scss
+++ b/src/styles/layout/_layout.scss
@@ -14,8 +14,8 @@
}
.main {
- background: var(--panel);
- border: 1px solid var(--stroke);
+ background: transparent;
+ border: none;
border-radius: var(--panel-radius);
padding: var(--main-padding);
min-height: 0;
@@ -23,9 +23,3 @@
flex-direction: column;
min-width: 0;
}
-
-// Compact mode removes the outer container frame while keeping the content intact.
-:root[data-compact="true"] .main {
- background: transparent;
- border: none;
-}
diff --git a/src/styles/tokens/_theme.scss b/src/styles/tokens/_theme.scss
index 116801a..73becb8 100644
--- a/src/styles/tokens/_theme.scss
+++ b/src/styles/tokens/_theme.scss
@@ -44,19 +44,22 @@ $accent-themes: (
--surface-control: #151923;
--surface-control-strong: #151a26;
--stroke: #1b2130;
+ --window-frame-edge: rgba(255, 255, 255, 0.08);
+ --window-chrome-bar-height: 34px;
--text: #e2e6f2;
--muted: #8c95aa;
--accent: #e14c4c;
--accent-rgb: 225, 76, 76;
--topstack-height: 120px;
--statusbar-height: 30px;
- // Layout sizing tokens to keep density changes centralized.
- --layout-gap: 16px;
- --layout-padding: 12px 16px 16px;
- --panel-radius: 10px;
+ // Layout sizing tokens for the default edge-to-edge shell.
+ --layout-gap: 0;
+ --layout-padding: 0;
+ --panel-radius: 0;
--sidebar-padding: 16px;
- --main-padding: 12px;
+ --main-padding: 0;
--thumb-grid-margin: 0;
+ --content-edge-gap: 8px;
}
@each $name, $theme in $accent-themes {
@@ -74,13 +77,3 @@ $accent-themes: (
--ambient-glow-mid: rgba(var(--accent-rgb), 0.32);
--ambient-glow-soft: rgba(var(--accent-rgb), 0.18);
}
-
-// Compact mode trims layout chrome for an edge-to-edge workspace.
-:root[data-compact="true"] {
- --layout-gap: 0;
- --layout-padding: 0;
- --panel-radius: 0;
- --main-padding: 0;
- --thumb-grid-margin: 0;
- --compact-edge-gap: 8px;
-}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 165a9ba..6edb6eb 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -1,10 +1,11 @@
{
- "compilerOptions": {
- "composite": true,
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true
- },
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "types": ["node"]
+ },
"include": ["vite.config.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index 9881a82..f853005 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,11 +3,12 @@ import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
-// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
+const localDevHost = "127.0.0.1";
+const devPort = 1420;
const rootDir = fileURLToPath(new URL(".", import.meta.url));
-
-// https://vite.dev/config/
+
+// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
resolve: {
@@ -64,22 +65,23 @@ export default defineConfig(async () => ({
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
- clearScreen: false,
- // 2. tauri expects a fixed port, fail if that port is not available
- server: {
- port: 1421,
- strictPort: true,
- host: host || false,
- hmr: host
- ? {
- protocol: "ws",
- host,
- port: 1421,
- }
- : undefined,
- watch: {
- // 3. tell Vite to ignore watching `src-tauri`
- ignored: ["**/src-tauri/**"],
- },
- },
-}));
+ clearScreen: false,
+ // 2. tauri expects a fixed port, fail if that port is not available
+ server: {
+ port: devPort,
+ strictPort: true,
+ // Use an explicit IPv4 loopback address so Windows does not resolve `localhost` to `::1`.
+ host: host || localDevHost,
+ hmr: host
+ ? {
+ protocol: "ws",
+ host,
+ port: devPort,
+ }
+ : undefined,
+ watch: {
+ // 3. tell Vite to ignore watching `src-tauri`
+ ignored: ["**/src-tauri/**"],
+ },
+ },
+}));