Skip to content

Commit

Permalink
Merge pull request #1 from Vafilor/feat/image-cache
Browse files Browse the repository at this point in the history
Cache image icons when viewing files in icon mode
  • Loading branch information
Vafilor authored Dec 21, 2023
2 parents 9297e3b + f5e9e59 commit f90442e
Show file tree
Hide file tree
Showing 22 changed files with 1,090 additions and 27 deletions.
554 changes: 550 additions & 4 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"@electron-forge/maker-zip": "^7.2.0",
"@electron-forge/plugin-auto-unpack-natives": "^7.2.0",
"@electron-forge/plugin-webpack": "^7.2.0",
"@types/heic-convert": "^1.2.3",
"@types/heic-decode": "^1.1.2",
"@types/jest": "^29.5.11",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.17",
Expand Down Expand Up @@ -71,10 +73,12 @@
"drizzle-orm": "^0.29.1",
"electron-squirrel-startup": "^1.0.0",
"fswin": "^3.23.311",
"heic-decode": "^2.0.0",
"rc-virtual-list": "^3.11.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-virtualized-auto-sizer": "^1.0.20"
"react-virtualized-auto-sizer": "^1.0.20",
"sharp": "^0.33.1"
}
}
}
2 changes: 1 addition & 1 deletion src/components/file-browser/file-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function FileBrowser({ path: initialPath, config, className }: Props) {

export default function PathFileBrowser() {
// These values provide the initial state for the application
// It is not necessary for them to be up-to-date
// It is not necessary for them to update the FileBrowser on change, so we only get them once.
const { value: homeDirectory, loading: homeDirectoryLoading } = useAwaitValue(() => FileSystemClient.instance.getUserHomeDirectory());
const { value: config, loading: configLoading } = useAwaitValue(() => Configuration.instance.getOptions());

Expand Down
28 changes: 28 additions & 0 deletions src/components/file-list/icon-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import useAwaitValue from "app/hooks/useAwaitValue";
import FileSystemClient from "app/services/filesystem-client";
import ImageFile from "../file-view/image-file";
import Rectangle from "../loading/rectangle";

type Props = Omit<
React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>,
"src" | "width" | "height"> & { src: string, width: number, height: number }

export default function IconImage({ src, width, height, ...otherProps }: Props) {
const { value: cachedImagePath, loading, error } = useAwaitValue(() => FileSystemClient.instance.getImageIconPath(src, width, height));

if (loading) {
return <Rectangle width={width} height={height} />
}

if (error) {
return <div>Error</div>;
}

return <ImageFile
src={cachedImagePath}
width={width}
height={height}
{...otherProps}
/>

}
3 changes: 2 additions & 1 deletion src/components/file-list/icon-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import FileIcon from "../file-icon";
import { isImageExtension, getExtension } from "../../utils/files";
import { partitionList } from "../../utils/collections";
import ImageFile from "../file-view/image-file";
import IconImage from "./icon-image";

interface IconItemProps {
file: AppFile;
Expand Down Expand Up @@ -35,7 +36,7 @@ function IconItem({ file, width, height, setPath }: IconItemProps) {
if (isImage) {
return (
<FileIconWrapper file={file} width={width} height={height} setPath={setPath}>
<ImageFile
<IconImage
width={width}
height={childHeight - 8}
src={file.path}
Expand Down
18 changes: 15 additions & 3 deletions src/components/file-view/file-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import useAwaitValue from "app/hooks/useAwaitValue";
import FileSystemClient from "app/services/filesystem-client";
import VideoFile from "./video-file";
import Layout from "./layout";
import PathClient from "app/services/path";
import HeicImageFile from "./heic-image-file";
import Rectangle from "../loading/rectangle";

interface Props {
file: AppFile;
Expand All @@ -28,6 +29,14 @@ export default function FileView({ file: partialFile }: Props) {
const isImage = extension ? isImageExtension(extension) : false;

if (isImage) {
if (extension?.toLowerCase() === "heic") {
return (
<Layout>
<HeicImageFile src={partialFile.path} />
</Layout>
);
}

return (
<Layout>
<ImageFile
Expand Down Expand Up @@ -55,9 +64,12 @@ export default function FileView({ file: partialFile }: Props) {
);
}

// TODO better loading UI
if (loading || !file) {
return <div>Loading</div>;
return (
<Layout>
<Rectangle />
</Layout>
);
}

if (!viewLargeFile && isTooBig(file.size)) {
Expand Down
42 changes: 42 additions & 0 deletions src/components/file-view/heic-image-file.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import useAwaitValue from "app/hooks/useAwaitValue";
import FileSystemClient from "app/services/filesystem-client";
import PathClient from "app/services/path"
import { useEffect, useRef } from "react";

interface Props {
src: string;
}

export default function HeicImageFile({ src }: Props) {
const ref = useRef<HTMLCanvasElement | null>(null);

const data = useAwaitValue(() => FileSystemClient.instance.getHeicFile(
PathClient.instance.toLocalURL(src))
);

useEffect(() => {
if (!ref.current) {
return;
}

if (!data.value) {
return;
}

const context = ref.current.getContext('2d');
if (!context) {
return;
}

ref.current.width = data.value.width;
ref.current.height = data.value.height;

const imageData = new ImageData(data.value.data, data.value.width, data.value.height);

context.putImageData(imageData, 0, 0);
}, [data]);

return (
<canvas ref={ref}></canvas>
);
}
23 changes: 18 additions & 5 deletions src/components/file-view/text-file.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import FileSystemClient from "app/services/filesystem-client";
import useAwaitValue from "app/hooks/useAwaitValue";
import { AppFile } from "app/types/filesystem";
import { RectangleList } from "../loading/rectangle-list";
import { useWindowSize } from "@uidotdev/usehooks";
import { useMemo } from "react";

interface Props {
file: AppFile;
}

const HEIGHT = 12;
const GAP = 10;

export default function TextFile({ file }: Props) {
const { value: content, loading } = useAwaitValue(
() => FileSystemClient.instance.getTextFileContext(file.path)
);
const { value: content, loading } = useAwaitValue(() => FileSystemClient.instance.getTextFileContext(file.path));

const { height: windowHeight } = useWindowSize();
const count = useMemo(() => {
if (windowHeight === null) {
return 10;
}

// Rough calculation to make sure we have nough bars to cover the ui
return Math.max(10, Math.floor((windowHeight - 40) / (HEIGHT + GAP)));
}, [windowHeight]);

// TODO better loading ui
if (loading) {
return <div>Loading</div>;
return <RectangleList width="100%" height={HEIGHT} count={count} gap={GAP} />
}

return (
Expand Down
19 changes: 19 additions & 0 deletions src/components/loading/rectangle-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Rectangle from "./rectangle";

interface Props {
width?: number | string;
height?: number | string;
count: number;
gap: number;
className?: string;
}

export function RectangleList({ width, height, count, gap, className }: Props) {
return (
<div className={"flex flex-col" + (className || "")} style={{ gap, width }}>
{(new Array(count).fill(0)).map((_, index) => (
<Rectangle key={index} width={width} height={height} />
))}
</div>
);
}
11 changes: 11 additions & 0 deletions src/components/loading/rectangle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
interface Props {
width?: number | string;
height?: number | string;
}

export default function Rectangle({ width, height }: Props) {
return (
<div className="animate-pulse rounded bg-slate-200" style={{ width, height }}>
</div>
);
}
73 changes: 66 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { app, BrowserWindow, ipcMain, IpcMainInvokeEvent, protocol, net } from 'electron';
import { readdir, stat, readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { AppFile, ReaddirOptions } from './types/filesystem';
import { readdir, stat, readFile, mkdir } from "node:fs/promises";
import { basename, resolve, sep, extname } from "node:path";
import { AppFile, HeicFileResponse, ReaddirOptions } from './types/filesystem';
import Store from './configuration/store';
import * as fswin from "fswin";
import { fileExists } from './server/filesystem';
import WorkerPool from './workers/worker-pool';
import os from 'node:os';
import { TaskAction } from './workers/types';

// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;

const pool = new WorkerPool(os.availableParallelism());

function formatWindowsAppURL(url: string): string {
if (url.length === 1) {
return url + ":/";
Expand Down Expand Up @@ -70,12 +76,57 @@ async function filesystemGetTextFileContext(event: IpcMainInvokeEvent, path: str
return readFile(path, { encoding: "utf-8" });
}

async function filesystemGetImageIconPath(event: IpcMainInvokeEvent, path: string, width: number, height: number): Promise<string> {
const home = app.getPath('userData');
const imageCache = resolve(home, "image_cache");

const ext = extname(path);
const outputPath = path.slice(0, path.length - ext.length) + ".jpg"
const formattedPath = outputPath.replaceAll(sep, "_");

const cachedFilePath = resolve(imageCache, formattedPath);

if (await fileExists(cachedFilePath)) {
return cachedFilePath;
}

// Cached file does not exist, create it.

return new Promise((resolve) => {
pool.runTask({
type: TaskAction.CreateIcon,
inputPath: path,
outputPath: cachedFilePath,
width,
height
}, (err) => {
if (err) {
console.error(err);
return;
}

resolve(cachedFilePath);
});
});
}

async function filesystemGetHeicFile(event: IpcMainInvokeEvent, path: string): Promise<HeicFileResponse> {
return new Promise((resolve) => {
pool.runTask({
type: TaskAction.LoadHeicData,
path,
}, (err: any, result: any) => {
resolve(result);
});
});
}

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
app.quit();
}

// Allow showing filesystem images in app.
// Allow showing filesystem images and videos in app.
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { bypassCSP: true, stream: true } }
]);
Expand All @@ -100,16 +151,25 @@ const createWindow = (): void => {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
const store = new Store(resolve(app.getPath('userData'), "configuration.json"));
app.whenReady().then(async () => {
const userDataPath = app.getPath('userData');
const store = new Store(resolve(userDataPath, "configuration.json"));

const imageCache = resolve(userDataPath, "image_cache");
if (!await fileExists(imageCache)) {
await mkdir(imageCache)
}

ipcMain.handle('filesystem-list', filesystemList);
ipcMain.handle('filesystem-get-text-file', filesystemGetTextFileContext);
ipcMain.handle('filesystem-file-stat', filesystemFileStat);
ipcMain.handle('filesystem-get-home-directory', () => app.getPath('home'));
ipcMain.handle('filesystem-get-image-icon-path', filesystemGetImageIconPath)
ipcMain.handle('config-get', store.getOptions.bind(store));
ipcMain.handle('config-update', store.update.bind(store));

ipcMain.handle('filesystem-get-heic-file', filesystemGetHeicFile);

const formatAppURL = process.platform === "win32" ? formatWindowsAppURL : formatIxAppURL;

protocol.handle('app', (request) => {
Expand All @@ -118,7 +178,6 @@ app.whenReady().then(() => {
return net.fetch("file://" + formatAppURL(url));
});


createWindow();
});

Expand Down
3 changes: 3 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ contextBridge.exposeInMainWorld('appFilesystem', {
readdir: (path: PathLike, options?: ReaddirOptions)
: Promise<AppFile[]> => ipcRenderer.invoke('filesystem-list', path, options),
getUserHomeDirectory: () => ipcRenderer.invoke('filesystem-get-home-directory'),
getImageIconPath: (path: PathLike, width: number, height: number): Promise<string> =>
ipcRenderer.invoke('filesystem-get-image-icon-path', path, width, height),
getHeicFile: (path: PathLike) => ipcRenderer.invoke('filesystem-get-heic-file', path)
});

contextBridge.exposeInMainWorld('appConfig', {
Expand Down
10 changes: 10 additions & 0 deletions src/server/filesystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { access, constants } from "node:fs/promises";

export async function fileExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch (e: unknown) {
return false;
}
}
Loading

0 comments on commit f90442e

Please sign in to comment.