mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-30 11:23:07 -04:00
- Add a complete ephemeral indexing subsystem
- core/src/ops/core/ephemeral_status with input/output and query types
- core/src/ops/indexing/ephemeral with arena, cache, registry,
index_cache, types
- expose EphemeralIndexCache and EphemeralIndex through core modules
- EphemeralIndexCache supports
get/insert/create_for_indexing/mark_indexing_complete eviction and
stats
- Implement EphemeralIndex data structures for memory-efficient storage
- NodeArena, NameCache, NameRegistry, and related types
- Add EphemeralIndex status API
- EphemeralCacheStatusInput and EphemeralCacheStatusQuery
- EphemeralCacheStatus with per-index details
- Wire ephemeral indexing into the indexing flow
- Change default Ephemeral Indexer behavior to shallow mode
- Align code to EphemeralIndex usage across the codebase
- Enhance content kind detection in UI
- Add getContentKind(file) helper (prefers content_identity.kind, then
content_kind)
- Use getContentKind in Explorer utilities and UI components
- Invalidate directory listings when location index_mode changes
- Add useLocationChangeInvalidation to trigger refetches for ephemeral
vs persistent indexing transitions
- Misc refactors and formatting to accommodate the new modules and APIs
358 lines
9.4 KiB
TypeScript
358 lines
9.4 KiB
TypeScript
import type { File, ContentKind } from "@sd/ts-client";
|
|
import { File as FileComponent } from "../Explorer/File";
|
|
import { formatBytes, getContentKind } from "../Explorer/utils";
|
|
import { usePlatform } from "../../platform";
|
|
import { useState, useEffect, useRef } from "react";
|
|
import {
|
|
MagnifyingGlassPlus,
|
|
MagnifyingGlassMinus,
|
|
ArrowCounterClockwise,
|
|
} from "@phosphor-icons/react";
|
|
import { VideoPlayer } from "./VideoPlayer";
|
|
import { AudioPlayer } from "./AudioPlayer";
|
|
import { useZoomPan } from "./useZoomPan";
|
|
import { Folder } from "@sd/assets/icons";
|
|
|
|
interface ContentRendererProps {
|
|
file: File;
|
|
onZoomChange?: (isZoomed: boolean) => void;
|
|
}
|
|
|
|
function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
|
|
const platform = usePlatform();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [originalLoaded, setOriginalLoaded] = useState(false);
|
|
const [originalUrl, setOriginalUrl] = useState<string | null>(null);
|
|
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } =
|
|
useZoomPan(containerRef);
|
|
|
|
// Notify parent of zoom state changes
|
|
useEffect(() => {
|
|
onZoomChange?.(isZoomed);
|
|
}, [isZoomed, onZoomChange]);
|
|
|
|
useEffect(() => {
|
|
if (!platform.convertFileSrc) {
|
|
return;
|
|
}
|
|
|
|
const sdPath = file.sd_path as any;
|
|
const physicalPath = sdPath?.Physical?.path;
|
|
|
|
if (!physicalPath) {
|
|
console.log(
|
|
"[ImageRenderer] No physical path available, sd_path:",
|
|
file.sd_path,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const url = platform.convertFileSrc(physicalPath);
|
|
console.log(
|
|
"[ImageRenderer] Loading original from:",
|
|
physicalPath,
|
|
"-> URL:",
|
|
url,
|
|
);
|
|
setOriginalUrl(url);
|
|
}, [file, platform]);
|
|
|
|
// Get highest resolution thumbnail first
|
|
const getHighestResThumbnail = () => {
|
|
const thumbnails =
|
|
file.sidecars?.filter((s) => s.kind === "thumb") || [];
|
|
if (thumbnails.length === 0) return null;
|
|
|
|
const highest = thumbnails.sort((a, b) => {
|
|
const aSize = parseInt(
|
|
a.variant.split("x")[0]?.replace(/\D/g, "") || "0",
|
|
);
|
|
const bSize = parseInt(
|
|
b.variant.split("x")[0]?.replace(/\D/g, "") || "0",
|
|
);
|
|
return bSize - aSize;
|
|
})[0];
|
|
|
|
const serverUrl = (window as any).__SPACEDRIVE_SERVER_URL__;
|
|
const libraryId = (window as any).__SPACEDRIVE_LIBRARY_ID__;
|
|
const contentUuid = file.content_identity?.uuid;
|
|
|
|
if (!serverUrl || !libraryId || !contentUuid) return null;
|
|
|
|
return `${serverUrl}/sidecar/${libraryId}/${contentUuid}/${highest.kind}/${highest.variant}.${highest.format}`;
|
|
};
|
|
|
|
const thumbnailUrl = getHighestResThumbnail();
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`relative w-full h-full flex items-center justify-center ${isZoomed ? "overflow-visible" : "overflow-hidden"}`}
|
|
>
|
|
{/* Zoom Controls */}
|
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
|
<button
|
|
onClick={zoomIn}
|
|
className="rounded-lg bg-app-box/80 p-2 text-ink backdrop-blur-xl transition-colors hover:bg-app-hover"
|
|
title="Zoom in (+)"
|
|
>
|
|
<MagnifyingGlassPlus size={20} weight="bold" />
|
|
</button>
|
|
<button
|
|
onClick={zoomOut}
|
|
className="rounded-lg bg-app-box/80 p-2 text-ink backdrop-blur-xl transition-colors hover:bg-app-hover"
|
|
title="Zoom out (-)"
|
|
>
|
|
<MagnifyingGlassMinus size={20} weight="bold" />
|
|
</button>
|
|
{zoom > 1 && (
|
|
<button
|
|
onClick={reset}
|
|
className="rounded-lg bg-app-box/80 p-2 text-ink backdrop-blur-xl transition-colors hover:bg-app-hover"
|
|
title="Reset zoom (0)"
|
|
>
|
|
<ArrowCounterClockwise size={20} weight="bold" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Zoom level indicator */}
|
|
{zoom > 1 && (
|
|
<div className="absolute top-4 left-4 z-10 rounded-lg bg-app-box/80 px-3 py-1.5 text-sm font-medium text-ink backdrop-blur-xl">
|
|
{Math.round(zoom * 100)}%
|
|
</div>
|
|
)}
|
|
|
|
{/* Image container with zoom/pan transform */}
|
|
<div
|
|
className="relative w-full h-full flex items-center justify-center"
|
|
style={transform}
|
|
>
|
|
{/* High-res thumbnail (loads fast, shows immediately) */}
|
|
{thumbnailUrl && (
|
|
<img
|
|
src={thumbnailUrl}
|
|
alt={file.name}
|
|
className="w-full h-full object-contain"
|
|
style={{
|
|
opacity: originalLoaded ? 0 : 1,
|
|
transition: "opacity 0.3s",
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
|
|
{/* Original image (loads async, fades in when ready) */}
|
|
{originalUrl && (
|
|
<img
|
|
src={originalUrl}
|
|
alt={file.name}
|
|
className="absolute inset-0 w-full h-full object-contain"
|
|
style={{
|
|
opacity: originalLoaded ? 1 : 0,
|
|
transition: "opacity 0.3s",
|
|
}}
|
|
onLoad={() => setOriginalLoaded(true)}
|
|
onError={(e) =>
|
|
console.error(
|
|
"[ImageRenderer] Original failed to load:",
|
|
e,
|
|
)
|
|
}
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VideoRenderer({ file, onZoomChange }: ContentRendererProps) {
|
|
const platform = usePlatform();
|
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!platform.convertFileSrc) {
|
|
return;
|
|
}
|
|
|
|
const sdPath = file.sd_path as any;
|
|
const physicalPath = sdPath?.Physical?.path;
|
|
|
|
if (!physicalPath) {
|
|
console.log("[VideoRenderer] No physical path available");
|
|
return;
|
|
}
|
|
|
|
const url = platform.convertFileSrc(physicalPath);
|
|
console.log(
|
|
"[VideoRenderer] Loading video from:",
|
|
physicalPath,
|
|
"-> URL:",
|
|
url,
|
|
);
|
|
setVideoUrl(url);
|
|
}, [file, platform]);
|
|
|
|
if (!videoUrl) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<FileComponent.Thumb
|
|
file={file}
|
|
size={800}
|
|
className="max-w-full max-h-full"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<VideoPlayer src={videoUrl} file={file} onZoomChange={onZoomChange} />
|
|
);
|
|
}
|
|
|
|
function AudioRenderer({ file }: ContentRendererProps) {
|
|
const platform = usePlatform();
|
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!platform.convertFileSrc) {
|
|
return;
|
|
}
|
|
|
|
const sdPath = file.sd_path as any;
|
|
const physicalPath = sdPath?.Physical?.path;
|
|
|
|
if (!physicalPath) {
|
|
console.log("[AudioRenderer] No physical path available");
|
|
return;
|
|
}
|
|
|
|
const url = platform.convertFileSrc(physicalPath);
|
|
console.log(
|
|
"[AudioRenderer] Loading audio from:",
|
|
physicalPath,
|
|
"-> URL:",
|
|
url,
|
|
);
|
|
setAudioUrl(url);
|
|
}, [file, platform]);
|
|
|
|
if (!audioUrl) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<div className="text-center">
|
|
<FileComponent.Thumb file={file} size={200} />
|
|
<div className="mt-6 text-ink text-lg font-medium">
|
|
{file.name}
|
|
</div>
|
|
<div className="text-ink-dull text-sm mt-2">Loading...</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <AudioPlayer src={audioUrl} file={file} />;
|
|
}
|
|
|
|
function DocumentRenderer({ file }: ContentRendererProps) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<div className="text-center">
|
|
<FileComponent.Thumb file={file} size={200} />
|
|
<div className="mt-6 text-ink text-lg font-medium">
|
|
{file.name}
|
|
</div>
|
|
<div className="text-ink-dull text-sm mt-2 capitalize">
|
|
{getContentKind(file) ?? "unknown"}
|
|
</div>
|
|
<div className="text-ink-dull text-xs mt-1">
|
|
{formatBytes(file.size || 0)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextRenderer({ file }: ContentRendererProps) {
|
|
// TODO: Load actual text content
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<div className="text-center max-w-xl">
|
|
<FileComponent.Thumb file={file} size={120} />
|
|
<div className="mt-4 text-ink text-lg font-medium">
|
|
{file.name}
|
|
</div>
|
|
<div className="text-ink-dull text-sm mt-2">Text File</div>
|
|
<div className="text-ink-dull text-xs mt-1">
|
|
{formatBytes(file.size || 0)}
|
|
</div>
|
|
<div className="mt-4 text-xs text-ink-dull">
|
|
Full text preview coming soon
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DefaultRenderer({ file }: ContentRendererProps) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<div className="text-center">
|
|
<FileComponent.Thumb file={file} size={200} />
|
|
<div className="mt-6 text-ink text-lg font-medium">
|
|
{file.name}
|
|
</div>
|
|
<div className="text-ink-dull text-sm mt-2 capitalize">
|
|
{getContentKind(file) ?? "unknown"}
|
|
</div>
|
|
<div className="text-ink-dull text-xs mt-1">
|
|
{formatBytes(file.size || 0)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) {
|
|
// Handle directories first
|
|
if (file.kind.type === "Directory") {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full text-ink-dull">
|
|
<img
|
|
src={Folder}
|
|
alt="Folder Icon"
|
|
className="w-16 h-16 mb-4"
|
|
/>
|
|
<div className="text-lg font-medium text-ink">{file.name}</div>
|
|
<div className="text-sm mt-2">Folder</div>
|
|
{file.size > 0 && (
|
|
<div className="text-xs mt-1">{formatBytes(file.size)}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const kind = getContentKind(file);
|
|
|
|
switch (kind) {
|
|
case "image":
|
|
return <ImageRenderer file={file} onZoomChange={onZoomChange} />;
|
|
case "video":
|
|
return <VideoRenderer file={file} onZoomChange={onZoomChange} />;
|
|
case "audio":
|
|
return <AudioRenderer file={file} />;
|
|
case "document":
|
|
case "book":
|
|
case "spreadsheet":
|
|
case "presentation":
|
|
return <DocumentRenderer file={file} />;
|
|
case "text":
|
|
case "code":
|
|
case "config":
|
|
return <TextRenderer file={file} />;
|
|
default:
|
|
return <DefaultRenderer file={file} />;
|
|
}
|
|
}
|