mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 05:45:01 -04:00
Merge pull request #2929 from spacedriveapp/cursor/demon-startup-overlay-a536
Demon startup overlay
This commit is contained in:
@@ -178,6 +178,11 @@ export const platform: Platform = {
|
||||
return unlisten;
|
||||
},
|
||||
|
||||
async getAppVersion() {
|
||||
const { getVersion } = await import("@tauri-apps/api/app");
|
||||
return await getVersion();
|
||||
},
|
||||
|
||||
async getDaemonStatus() {
|
||||
return await invoke<{
|
||||
is_running: boolean;
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"clsx": "^2.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"ogl": "^1.0.11",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useLocation,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import { useEffect, useMemo, memo } from "react";
|
||||
import { useEffect, useMemo, memo, useRef } from "react";
|
||||
import { Dialogs } from "@sd/ui";
|
||||
import { Inspector, type InspectorVariant } from "./Inspector";
|
||||
import { TopBarProvider, TopBar } from "./TopBar";
|
||||
@@ -49,6 +49,8 @@ import { useState } from "react";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { File as FileComponent } from "./components/Explorer/File";
|
||||
import { DaemonDisconnectedOverlay } from "./components/DaemonDisconnectedOverlay";
|
||||
import { DaemonStartupOverlay } from "./components/DaemonStartupOverlay";
|
||||
import { useDaemonStatus } from "./hooks/useDaemonStatus";
|
||||
import { useFileOperationDialog } from "./components/FileOperationModal";
|
||||
import { House, Clock, Heart, Folders } from "@phosphor-icons/react";
|
||||
import {
|
||||
@@ -865,20 +867,68 @@ function ExplorerWithTabs() {
|
||||
}
|
||||
|
||||
export function Explorer({ client }: AppProps) {
|
||||
const platform = usePlatform();
|
||||
const isTauri = platform.platform === "tauri";
|
||||
|
||||
return (
|
||||
<SpacedriveProvider client={client}>
|
||||
<ServerProvider>
|
||||
<TabManagerProvider routes={explorerRoutes}>
|
||||
<TabKeyboardHandler />
|
||||
<ExplorerWithTabs />
|
||||
</TabManagerProvider>
|
||||
<DaemonDisconnectedOverlay />
|
||||
<Dialogs />
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-right"
|
||||
/>
|
||||
{isTauri ? (
|
||||
// Tauri: Wait for daemon connection before rendering content
|
||||
<ExplorerWithDaemonCheck />
|
||||
) : (
|
||||
// Web: Render immediately (daemon connection handled differently)
|
||||
<>
|
||||
<TabManagerProvider routes={explorerRoutes}>
|
||||
<TabKeyboardHandler />
|
||||
<ExplorerWithTabs />
|
||||
</TabManagerProvider>
|
||||
<Dialogs />
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-right"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ServerProvider>
|
||||
</SpacedriveProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri-specific wrapper that prevents Explorer from rendering until daemon is connected.
|
||||
* This avoids the connection storm where hundreds of queries try to execute before daemon is ready.
|
||||
*/
|
||||
function ExplorerWithDaemonCheck() {
|
||||
const daemonStatus = useDaemonStatus();
|
||||
const { isConnected, isStarting } = daemonStatus;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isConnected ? (
|
||||
// Daemon connected - render full app
|
||||
<>
|
||||
<TabManagerProvider routes={explorerRoutes}>
|
||||
<TabKeyboardHandler />
|
||||
<ExplorerWithTabs />
|
||||
</TabManagerProvider>
|
||||
<Dialogs />
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-right"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Daemon not connected - show appropriate overlay
|
||||
<>
|
||||
<DaemonStartupOverlay show={isStarting} />
|
||||
{!isStarting && (
|
||||
<DaemonDisconnectedOverlay
|
||||
daemonStatus={daemonStatus}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Copy } from "@phosphor-icons/react";
|
||||
import { useDaemonStatus } from "../hooks/useDaemonStatus";
|
||||
@@ -34,8 +34,10 @@ function CLICommand({
|
||||
|
||||
export function DaemonDisconnectedOverlay({
|
||||
forceShow = false,
|
||||
daemonStatus,
|
||||
}: {
|
||||
forceShow?: boolean;
|
||||
daemonStatus: ReturnType<typeof useDaemonStatus>;
|
||||
}) {
|
||||
const {
|
||||
isConnected,
|
||||
@@ -43,9 +45,8 @@ export function DaemonDisconnectedOverlay({
|
||||
isInstalled,
|
||||
startDaemon,
|
||||
installAndStartDaemon,
|
||||
} = useDaemonStatus();
|
||||
} = daemonStatus;
|
||||
const [installAsService, setInstallAsService] = useState(isInstalled);
|
||||
const prevConnected = useRef(isConnected);
|
||||
const platform = usePlatform();
|
||||
|
||||
// Update checkbox when installation state changes
|
||||
@@ -65,22 +66,6 @@ export function DaemonDisconnectedOverlay({
|
||||
);
|
||||
}, [installAsService]);
|
||||
|
||||
// Reload when connection state changes from false to true
|
||||
useEffect(() => {
|
||||
console.log("Daemon status changed:", {
|
||||
isConnected,
|
||||
prevConnected: prevConnected.current,
|
||||
forceShow,
|
||||
});
|
||||
|
||||
if (prevConnected.current === false && isConnected === true) {
|
||||
console.log("Daemon reconnected! Reloading app...");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
prevConnected.current = isConnected;
|
||||
}, [isConnected, forceShow]);
|
||||
|
||||
const shouldShow = forceShow || !isConnected;
|
||||
|
||||
return (
|
||||
|
||||
97
packages/interface/src/components/DaemonStartupOverlay.tsx
Normal file
97
packages/interface/src/components/DaemonStartupOverlay.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Ball } from "@sd/assets/images";
|
||||
import { CircleNotch } from "@phosphor-icons/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePlatform } from "../platform";
|
||||
import Orb from "./Orb";
|
||||
|
||||
export function DaemonStartupOverlay({ show }: { show: boolean }) {
|
||||
const platform = usePlatform();
|
||||
const [version, setVersion] = useState<string>("2.0.0");
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
// Get version from platform abstraction
|
||||
useEffect(() => {
|
||||
const getVersion = async () => {
|
||||
try {
|
||||
if (platform.getAppVersion) {
|
||||
const appVersion = await platform.getAppVersion();
|
||||
setVersion(appVersion);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback if platform doesn't support version or API fails
|
||||
setVersion("2.0.0-pre.1");
|
||||
}
|
||||
};
|
||||
getVersion();
|
||||
}, [platform]);
|
||||
|
||||
const versionText = isDev ? `${version} (dev)` : version;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black"
|
||||
>
|
||||
{/* Animated orb with ball */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="relative w-64 h-64"
|
||||
>
|
||||
{/* Ball image - behind the orb */}
|
||||
<div className="absolute inset-[8%] z-0">
|
||||
<img
|
||||
src={Ball}
|
||||
alt="Spacedrive"
|
||||
className="w-full h-full object-contain select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{/* Orb animation - inset to make it smaller */}
|
||||
<div className="absolute inset-[15%] z-10">
|
||||
<Orb
|
||||
hue={-30}
|
||||
hoverIntensity={0}
|
||||
rotateOnHover={false}
|
||||
forceHoverState={true}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Loading text - bottom right */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="fixed bottom-6 right-6 flex items-center gap-3"
|
||||
>
|
||||
<CircleNotch
|
||||
className="size-5 animate-spin text-white"
|
||||
weight="bold"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-lg font-bold text-white">
|
||||
Starting Spacedrive
|
||||
</p>
|
||||
<p className="text-sm text-white/50">
|
||||
v{versionText}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export function ExplorerView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex w-full flex-col h-full overflow-hidden bg-app/80">
|
||||
<div className="relative flex w-full flex-col pt-1.5 h-full overflow-hidden bg-app/80">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TabNavigationGuard>
|
||||
{viewMode === "grid" ? (
|
||||
|
||||
302
packages/interface/src/components/Orb.tsx
Normal file
302
packages/interface/src/components/Orb.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Renderer, Program, Mesh, Triangle, Vec3 } from "ogl";
|
||||
|
||||
interface OrbProps {
|
||||
hue?: number;
|
||||
hoverIntensity?: number;
|
||||
rotateOnHover?: boolean;
|
||||
forceHoverState?: boolean;
|
||||
}
|
||||
|
||||
export default function Orb({
|
||||
hue = 0,
|
||||
hoverIntensity = 0.2,
|
||||
rotateOnHover = true,
|
||||
forceHoverState = false,
|
||||
}: OrbProps) {
|
||||
const ctnDom = useRef<HTMLDivElement>(null);
|
||||
|
||||
const vert = /* glsl */ `
|
||||
precision highp float;
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const frag = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float hue;
|
||||
uniform float hover;
|
||||
uniform float rot;
|
||||
uniform float hoverIntensity;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 rgb2yiq(vec3 c) {
|
||||
float y = dot(c, vec3(0.299, 0.587, 0.114));
|
||||
float i = dot(c, vec3(0.596, -0.274, -0.322));
|
||||
float q = dot(c, vec3(0.211, -0.523, 0.312));
|
||||
return vec3(y, i, q);
|
||||
}
|
||||
|
||||
vec3 yiq2rgb(vec3 c) {
|
||||
float r = c.x + 0.956 * c.y + 0.621 * c.z;
|
||||
float g = c.x - 0.272 * c.y - 0.647 * c.z;
|
||||
float b = c.x - 1.106 * c.y + 1.703 * c.z;
|
||||
return vec3(r, g, b);
|
||||
}
|
||||
|
||||
vec3 adjustHue(vec3 color, float hueDeg) {
|
||||
float hueRad = hueDeg * 3.14159265 / 180.0;
|
||||
vec3 yiq = rgb2yiq(color);
|
||||
float cosA = cos(hueRad);
|
||||
float sinA = sin(hueRad);
|
||||
float i = yiq.y * cosA - yiq.z * sinA;
|
||||
float q = yiq.y * sinA + yiq.z * cosA;
|
||||
yiq.y = i;
|
||||
yiq.z = q;
|
||||
return yiq2rgb(yiq);
|
||||
}
|
||||
|
||||
vec3 hash33(vec3 p3) {
|
||||
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
|
||||
p3 += dot(p3, p3.yxz + 19.19);
|
||||
return -1.0 + 2.0 * fract(vec3(
|
||||
p3.x + p3.y,
|
||||
p3.x + p3.z,
|
||||
p3.y + p3.z
|
||||
) * p3.zyx);
|
||||
}
|
||||
|
||||
float snoise3(vec3 p) {
|
||||
const float K1 = 0.333333333;
|
||||
const float K2 = 0.166666667;
|
||||
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
|
||||
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
|
||||
vec3 e = step(vec3(0.0), d0 - d0.yzx);
|
||||
vec3 i1 = e * (1.0 - e.zxy);
|
||||
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
|
||||
vec3 d1 = d0 - (i1 - K2);
|
||||
vec3 d2 = d0 - (i2 - K1);
|
||||
vec3 d3 = d0 - 0.5;
|
||||
vec4 h = max(0.6 - vec4(
|
||||
dot(d0, d0),
|
||||
dot(d1, d1),
|
||||
dot(d2, d2),
|
||||
dot(d3, d3)
|
||||
), 0.0);
|
||||
vec4 n = h * h * h * h * vec4(
|
||||
dot(d0, hash33(i)),
|
||||
dot(d1, hash33(i + i1)),
|
||||
dot(d2, hash33(i + i2)),
|
||||
dot(d3, hash33(i + 1.0))
|
||||
);
|
||||
return dot(vec4(31.316), n);
|
||||
}
|
||||
|
||||
vec4 extractAlpha(vec3 colorIn) {
|
||||
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
|
||||
return vec4(colorIn.rgb / (a + 1e-5), a);
|
||||
}
|
||||
|
||||
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
|
||||
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
|
||||
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
|
||||
const float innerRadius = 0.6;
|
||||
const float noiseScale = 0.65;
|
||||
|
||||
float light1(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * attenuation);
|
||||
}
|
||||
|
||||
float light2(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * dist * attenuation);
|
||||
}
|
||||
|
||||
vec4 draw(vec2 uv) {
|
||||
vec3 color1 = adjustHue(baseColor1, hue);
|
||||
vec3 color2 = adjustHue(baseColor2, hue);
|
||||
vec3 color3 = adjustHue(baseColor3, hue);
|
||||
|
||||
float ang = atan(uv.y, uv.x);
|
||||
float len = length(uv);
|
||||
float invLen = len > 0.0 ? 1.0 / len : 0.0;
|
||||
|
||||
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
|
||||
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
|
||||
float d0 = distance(uv, (r0 * invLen) * uv);
|
||||
float v0 = light1(1.0, 10.0, d0);
|
||||
v0 *= smoothstep(r0 * 1.05, r0, len);
|
||||
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
|
||||
|
||||
float a = iTime * -1.0;
|
||||
vec2 pos = vec2(cos(a), sin(a)) * r0;
|
||||
float d = distance(uv, pos);
|
||||
float v1 = light2(1.5, 5.0, d);
|
||||
v1 *= light1(1.0, 50.0, d0);
|
||||
|
||||
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
|
||||
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
|
||||
|
||||
vec3 col = mix(color1, color2, cl);
|
||||
col = mix(color3, col, v0);
|
||||
col = (col + v1) * v2 * v3;
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
|
||||
return extractAlpha(col);
|
||||
}
|
||||
|
||||
vec4 mainImage(vec2 fragCoord) {
|
||||
vec2 center = iResolution.xy * 0.5;
|
||||
float size = min(iResolution.x, iResolution.y);
|
||||
vec2 uv = (fragCoord - center) / size * 2.0;
|
||||
|
||||
float angle = rot;
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
||||
|
||||
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
|
||||
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
|
||||
|
||||
return draw(uv);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord = vUv * iResolution.xy;
|
||||
vec4 col = mainImage(fragCoord);
|
||||
gl_FragColor = vec4(col.rgb * col.a, col.a);
|
||||
}
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const container = ctnDom.current;
|
||||
if (!container) return;
|
||||
|
||||
const renderer = new Renderer({
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
});
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
container.appendChild(gl.canvas);
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
const program = new Program(gl, {
|
||||
vertex: vert,
|
||||
fragment: frag,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: {
|
||||
value: new Vec3(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height,
|
||||
),
|
||||
},
|
||||
hue: { value: hue },
|
||||
hover: { value: 0 },
|
||||
rot: { value: 0 },
|
||||
hoverIntensity: { value: hoverIntensity },
|
||||
},
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
function resize() {
|
||||
if (!container) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
renderer.setSize(width * dpr, height * dpr);
|
||||
gl.canvas.style.width = width + "px";
|
||||
gl.canvas.style.height = height + "px";
|
||||
program.uniforms.iResolution.value.set(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height,
|
||||
);
|
||||
}
|
||||
window.addEventListener("resize", resize);
|
||||
resize();
|
||||
|
||||
let targetHover = 0;
|
||||
let lastTime = 0;
|
||||
let currentRot = 0;
|
||||
const rotationSpeed = 0.3;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const size = Math.min(width, height);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const uvX = ((x - centerX) / size) * 2.0;
|
||||
const uvY = ((y - centerY) / size) * 2.0;
|
||||
|
||||
if (Math.sqrt(uvX * uvX + uvY * uvY) < 0.8) {
|
||||
targetHover = 1;
|
||||
} else {
|
||||
targetHover = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
targetHover = 0;
|
||||
};
|
||||
|
||||
container.addEventListener("mousemove", handleMouseMove);
|
||||
container.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
let rafId: number;
|
||||
const update = (t: number) => {
|
||||
rafId = requestAnimationFrame(update);
|
||||
const dt = (t - lastTime) * 0.001;
|
||||
lastTime = t;
|
||||
program.uniforms.iTime.value = t * 0.001;
|
||||
program.uniforms.hue.value = hue;
|
||||
program.uniforms.hoverIntensity.value = hoverIntensity;
|
||||
|
||||
const effectiveHover = forceHoverState ? 1 : targetHover;
|
||||
program.uniforms.hover.value +=
|
||||
(effectiveHover - program.uniforms.hover.value) * 0.1;
|
||||
|
||||
if (rotateOnHover && effectiveHover > 0.5) {
|
||||
currentRot += dt * rotationSpeed;
|
||||
}
|
||||
program.uniforms.rot.value = currentRot;
|
||||
|
||||
renderer.render({ scene: mesh });
|
||||
};
|
||||
rafId = requestAnimationFrame(update);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", resize);
|
||||
container.removeEventListener("mousemove", handleMouseMove);
|
||||
container.removeEventListener("mouseleave", handleMouseLeave);
|
||||
container.removeChild(gl.canvas);
|
||||
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
};
|
||||
}, [hue, hoverIntensity, rotateOnHover, forceHoverState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ctnDom}
|
||||
className="absolute inset-0"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, LayoutGroup } from "framer-motion";
|
||||
import { Plus, X } from "@phosphor-icons/react";
|
||||
import { useTabManager } from "./useTabManager";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { tabs, activeTabId, switchTab, closeTab, createTab } =
|
||||
@@ -12,53 +13,66 @@ export function TabBar() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure activeTabId exists in tabs array, fallback to first tab
|
||||
// Memoize to prevent unnecessary rerenders during rapid state updates
|
||||
const safeActiveTabId = useMemo(() => {
|
||||
return tabs.find((t) => t.id === activeTabId)?.id ?? tabs[0]?.id;
|
||||
}, [tabs, activeTabId]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-9 px-1 gap-1 mx-2 mb-1.5 bg-app-box/50 rounded-full shrink-0">
|
||||
<div className="flex items-center flex-1 gap-1 min-w-0">
|
||||
{tabs.map((tab) => (
|
||||
<motion.button
|
||||
key={tab.id}
|
||||
layout
|
||||
onClick={() => switchTab(tab.id)}
|
||||
className={clsx(
|
||||
"relative flex items-center justify-center py-1.5 rounded-full text-[13px] group flex-1 min-w-0",
|
||||
tab.id === activeTabId
|
||||
? "text-ink"
|
||||
: "text-ink-dull hover:text-ink hover:bg-app-hover/50",
|
||||
)}
|
||||
>
|
||||
{tab.id === activeTabId && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute inset-0 bg-app-selected rounded-full shadow-sm"
|
||||
transition={{
|
||||
type: "easeInOut",
|
||||
duration: 0.15,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Close button - absolutely positioned left */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className={clsx(
|
||||
"absolute left-1.5 z-10 size-5 flex items-center justify-center rounded-full transition-all",
|
||||
tab.id === activeTabId
|
||||
? "opacity-60 hover:opacity-100 hover:bg-app-hover"
|
||||
: "opacity-0 group-hover:opacity-60 hover:!opacity-100 hover:bg-app-hover",
|
||||
)}
|
||||
title="Close tab"
|
||||
>
|
||||
<X size={10} weight="bold" />
|
||||
</button>
|
||||
<span className="relative z-10 truncate px-6">
|
||||
{tab.title}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center h-9 px-1 gap-1 mx-2 bg-app-box/50 rounded-full shrink-0">
|
||||
<LayoutGroup id="tab-bar">
|
||||
<div className="flex items-center flex-1 gap-1 min-w-0">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === safeActiveTabId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => switchTab(tab.id)}
|
||||
className={clsx(
|
||||
"relative flex items-center justify-center py-1.5 rounded-full text-[13px] group flex-1 min-w-0",
|
||||
isActive
|
||||
? "text-ink"
|
||||
: "text-ink-dull hover:text-ink hover:bg-app-hover/50",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute inset-0 bg-app-selected rounded-full shadow-sm"
|
||||
initial={false}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Close button - absolutely positioned left */}
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className={clsx(
|
||||
"absolute left-1.5 z-10 size-5 flex items-center justify-center rounded-full transition-all cursor-pointer",
|
||||
isActive
|
||||
? "opacity-60 hover:opacity-100 hover:bg-app-hover"
|
||||
: "opacity-0 group-hover:opacity-60 hover:!opacity-100 hover:bg-app-hover",
|
||||
)}
|
||||
title="Close tab"
|
||||
>
|
||||
<X size={10} weight="bold" />
|
||||
</span>
|
||||
<span className="relative z-10 truncate px-6">
|
||||
{tab.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
<button
|
||||
onClick={() => createTab()}
|
||||
className="size-7 flex items-center justify-center rounded-full hover:bg-app-hover text-ink-dull hover:text-ink shrink-0 transition-colors"
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
export interface DaemonStatus {
|
||||
isConnected: boolean;
|
||||
isChecking: boolean;
|
||||
isInstalled: boolean;
|
||||
/** True during initial app startup while waiting for daemon. Once connected, stays false. */
|
||||
isStarting: boolean;
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
const platform = usePlatform();
|
||||
// For Tauri, start in "starting" state. For web, assume connected.
|
||||
const isTauri = platform.platform === 'tauri';
|
||||
const [status, setStatus] = useState<DaemonStatus>({
|
||||
isConnected: true,
|
||||
isConnected: !isTauri, // Web is always "connected", Tauri starts disconnected
|
||||
isChecking: false,
|
||||
isInstalled: false,
|
||||
isStarting: isTauri, // Only Tauri starts in "starting" state
|
||||
});
|
||||
|
||||
// Track if we've ever been connected - once connected, isStarting stays false
|
||||
const hasEverConnected = useRef(!isTauri);
|
||||
|
||||
useEffect(() => {
|
||||
if (platform.platform !== 'tauri') {
|
||||
@@ -21,7 +29,6 @@ export function useDaemonStatus() {
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let checkInterval: NodeJS.Timeout | null = null;
|
||||
let listenerCleanup: (() => void) | null = null;
|
||||
|
||||
const checkDaemonStatus = async () => {
|
||||
@@ -31,18 +38,19 @@ export function useDaemonStatus() {
|
||||
const daemonStatus = await platform.getDaemonStatus?.();
|
||||
if (mounted) {
|
||||
const isRunning = daemonStatus?.is_running ?? false;
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isConnected: isRunning,
|
||||
// Only clear isChecking if we're connected (daemon started successfully)
|
||||
isChecking: isRunning ? false : prev.isChecking,
|
||||
}));
|
||||
|
||||
// Clear polling if daemon is back online
|
||||
if (isRunning && checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
|
||||
if (isRunning) {
|
||||
hasEverConnected.current = true;
|
||||
}
|
||||
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isConnected: isRunning,
|
||||
// Only clear isChecking if we're connected (daemon started successfully)
|
||||
isChecking: isRunning ? false : prev.isChecking,
|
||||
// Clear isStarting once we're connected
|
||||
isStarting: isRunning ? false : prev.isStarting,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
@@ -59,33 +67,29 @@ export function useDaemonStatus() {
|
||||
const unlistenConnected = await platform.onDaemonConnected?.(() => {
|
||||
console.log('[useDaemonStatus] daemon-connected event received');
|
||||
if (mounted) {
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
isChecking: false,
|
||||
}));
|
||||
|
||||
// Stop polling when connected
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
}
|
||||
hasEverConnected.current = true;
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
isChecking: false,
|
||||
isStarting: false, // No longer starting once connected
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenDisconnected = await platform.onDaemonDisconnected?.(() => {
|
||||
console.log('[useDaemonStatus] daemon-disconnected event received');
|
||||
if (mounted) {
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isChecking: false,
|
||||
}));
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isChecking: false,
|
||||
// If we were ever connected before, this is a disconnection, not startup
|
||||
// Keep isStarting as is - only clear it on connect
|
||||
isStarting: hasEverConnected.current ? false : prev.isStarting,
|
||||
}));
|
||||
|
||||
// Start polling when disconnected
|
||||
if (!checkInterval) {
|
||||
checkInterval = setInterval(checkDaemonStatus, 3000);
|
||||
}
|
||||
// Don't create additional polling - fallback interval already running
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,6 +99,9 @@ export function useDaemonStatus() {
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isChecking: true,
|
||||
// If daemon is starting (e.g., user clicked restart), show startup state
|
||||
// But only if we haven't connected yet in this session
|
||||
isStarting: !hasEverConnected.current,
|
||||
}));
|
||||
}
|
||||
});
|
||||
@@ -138,14 +145,12 @@ export function useDaemonStatus() {
|
||||
console.error('[useDaemonStatus] Failed to set up daemon listeners:', error);
|
||||
});
|
||||
|
||||
// Also poll every 5 seconds as a fallback
|
||||
const fallbackInterval = setInterval(checkDaemonStatus, 5000);
|
||||
// Fallback polling only when disconnected (event listeners should handle normal case)
|
||||
// Start with 3 second interval on startup
|
||||
const fallbackInterval = setInterval(checkDaemonStatus, 3000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
clearInterval(fallbackInterval);
|
||||
listenerCleanup?.();
|
||||
};
|
||||
|
||||
@@ -97,6 +97,9 @@ export type Platform = {
|
||||
/** Listen for selected file changes across all windows (Tauri only) */
|
||||
onSelectedFilesChanged?(callback: (fileIds: string[]) => void): Promise<() => void>;
|
||||
|
||||
/** Get app version (Tauri only) */
|
||||
getAppVersion?(): Promise<string>;
|
||||
|
||||
/** Get daemon status (Tauri only) */
|
||||
getDaemonStatus?(): Promise<{
|
||||
is_running: boolean;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { motion } from "framer-motion";
|
||||
import clsx from "clsx";
|
||||
import { CloudArrowUp, HardDrives, Files, Cpu } from "@phosphor-icons/react";
|
||||
|
||||
interface HeroStatsProps {
|
||||
@@ -32,11 +31,7 @@ export function HeroStats({
|
||||
totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-app-box border border-app-line rounded-2xl p-8"
|
||||
>
|
||||
<div className="bg-app-box border border-app-line rounded-2xl p-8">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{/* Total Storage */}
|
||||
<StatCard
|
||||
@@ -82,7 +77,7 @@ export function HeroStats({
|
||||
color="from-purple-500 to-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ export function OverviewTopBar({ libraryName }: OverviewTopBarProps) {
|
||||
trigger={
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
"bg-app-button/50 border border-app-line/50",
|
||||
"text-ink hover:bg-app-button",
|
||||
"focus:outline-none focus:ring-1 focus:ring-accent",
|
||||
"transition-colors",
|
||||
"flex items-center gap-2 h-8 px-3 rounded-full text-xs font-medium",
|
||||
"backdrop-blur-xl transition-all",
|
||||
"border border-sidebar-line/30",
|
||||
"bg-sidebar-box/20 text-sidebar-inkDull hover:bg-sidebar-box/30 hover:text-sidebar-ink",
|
||||
"active:scale-95",
|
||||
!currentLibrary && "text-ink-faint",
|
||||
)}
|
||||
>
|
||||
@@ -142,9 +142,7 @@ export function OverviewTopBar({ libraryName }: OverviewTopBarProps) {
|
||||
libraryName ||
|
||||
"Select Library"}
|
||||
</span>
|
||||
<span className="opacity-50">
|
||||
<CaretDown size={12} weight="bold" />
|
||||
</span>
|
||||
<CaretDown size={12} weight="bold" />
|
||||
</button>
|
||||
}
|
||||
className="p-2 min-w-[200px]"
|
||||
|
||||
@@ -80,7 +80,7 @@ export function Overview() {
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<div className="flex-1 flex gap-2 overflow-hidden">
|
||||
{/* Main content - scrollable */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<div className="flex-1 overflow-auto p-3 space-y-4">
|
||||
{/* Hero Stats */}
|
||||
<HeroStats
|
||||
totalStorage={stats.total_capacity}
|
||||
|
||||
Reference in New Issue
Block a user