Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ tauri-plugin-deeplink2 = { path = "plugins/deeplink2" }
tauri-plugin-detect = { path = "plugins/detect" }
tauri-plugin-export = { path = "plugins/export" }
tauri-plugin-extensions = { path = "plugins/extensions" }
tauri-plugin-folder = { path = "plugins/folder" }
tauri-plugin-hooks = { path = "plugins/hooks" }
tauri-plugin-icon = { path = "plugins/icon" }
tauri-plugin-importer = { path = "plugins/importer" }
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@hypr/plugin-deeplink2": "workspace:*",
"@hypr/plugin-detect": "workspace:*",
"@hypr/plugin-export": "workspace:*",
"@hypr/plugin-folder": "workspace:*",
"@hypr/plugin-extensions": "workspace:*",
"@hypr/plugin-hooks": "workspace:*",
"@hypr/plugin-icon": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ tauri-plugin-detect = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-export = { workspace = true }
tauri-plugin-extensions = { workspace = true }
tauri-plugin-folder = { workspace = true }
tauri-plugin-fs = { workspace = true }
tauri-plugin-hooks = { workspace = true }
tauri-plugin-http = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@
"os:default",
"detect:default",
"export:default",
"folder:default",
"permissions:default",
"settings:default",
"sfx:default",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ pub async fn main() {
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_deeplink2::init())
.plugin(tauri_plugin_export::init())
.plugin(tauri_plugin_folder::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_path2::init())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FolderIcon } from "lucide-react";
import { type ReactNode, useState } from "react";
import { type ReactNode, useCallback, useState } from "react";

import { commands as folderCommands } from "@hypr/plugin-folder";
import {
Command,
CommandEmpty,
Expand Down Expand Up @@ -31,13 +32,7 @@ export function SearchableFolderDropdown({
main.STORE_ID,
);

const handleSelectFolder = main.UI.useSetPartialRowCallback(
"sessions",
sessionId,
(folderId: string) => ({ folder_id: folderId }),
[],
main.STORE_ID,
);
const handleSelectFolder = useMoveSessionToFolder(sessionId);

return (
<DropdownMenu open={open} onOpenChange={setOpen}>
Expand Down Expand Up @@ -71,13 +66,7 @@ export function SearchableFolderSubmenuContent({
main.STORE_ID,
);

const handleSelectFolder = main.UI.useSetPartialRowCallback(
"sessions",
sessionId,
(folderId: string) => ({ folder_id: folderId }),
[],
main.STORE_ID,
);
const handleSelectFolder = useMoveSessionToFolder(sessionId);

return (
<DropdownMenuSubContent className="w-[200px] p-0">
Expand All @@ -102,11 +91,11 @@ function SearchableFolderContent({
setOpen,
}: {
folders: Record<string, any>;
onSelectFolder: (folderId: string) => void;
onSelectFolder: (folderId: string) => Promise<void>;
setOpen?: (open: boolean) => void;
}) {
const handleSelect = (folderId: string) => {
onSelectFolder(folderId);
const handleSelect = async (folderId: string) => {
await onSelectFolder(folderId);
setOpen?.(false);
};

Expand All @@ -131,3 +120,21 @@ function SearchableFolderContent({
</Command>
);
}

function useMoveSessionToFolder(sessionId: string) {
const setFolderId = main.UI.useSetPartialRowCallback(
"sessions",
sessionId,
(folderId: string) => ({ folder_id: folderId }),
[],
main.STORE_ID,
);

return useCallback(
async (targetFolderId: string) => {
await folderCommands.moveSession(sessionId, targetFolderId);
setFolderId(targetFolderId);
},
[sessionId, setFolderId],
);
}
176 changes: 176 additions & 0 deletions apps/desktop/src/store/tinybase/persister/folder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { exists, readDir } from "@tauri-apps/plugin-fs";
import { createCustomPersister } from "tinybase/persisters/with-schemas";
import type {
Content,
MergeableStore,
OptionalSchemas,
} from "tinybase/with-schemas";

import {
commands as notifyCommands,
events as notifyEvents,
} from "@hypr/plugin-notify";
import { commands as path2Commands } from "@hypr/plugin-path2";
import type { FolderStorage } from "@hypr/store";

import { DEFAULT_USER_ID } from "../../../utils";
import { StoreOrMergeableStore } from "../store/shared";
import { getParentFolderPath, type PersisterMode } from "./utils";

type FoldersJson = Record<string, FolderStorage>;

interface ScanResult {
folders: FoldersJson;
sessionFolderMap: Map<string, string>;
}

async function getSessionsDir(): Promise<string> {
const base = await path2Commands.base();
return `${base}/sessions`;
}

async function scanDirectoryRecursively(
sessionsDir: string,
currentPath: string = "",
): Promise<ScanResult> {
const folders: FoldersJson = {};
const sessionFolderMap = new Map<string, string>();

const fullPath = currentPath ? `${sessionsDir}/${currentPath}` : sessionsDir;

try {
const entries = await readDir(fullPath);

for (const entry of entries) {
if (!entry.isDirectory) {
continue;
}

const entryPath = currentPath
? `${currentPath}/${entry.name}`
: entry.name;

const hasMemoMd = await exists(`${sessionsDir}/${entryPath}/_memo.md`);

if (hasMemoMd) {
const folderPath = currentPath === "_default" ? "" : currentPath;
sessionFolderMap.set(entry.name, folderPath);
} else {
if (entry.name !== "_default") {
folders[entryPath] = {
user_id: DEFAULT_USER_ID,
created_at: new Date().toISOString(),
name: entry.name,
parent_folder_id: getParentFolderPath(entryPath),
};
}

const subResult = await scanDirectoryRecursively(
sessionsDir,
entryPath,
);

for (const [id, folder] of Object.entries(subResult.folders)) {
folders[id] = folder;
}
for (const [sessionId, folderPath] of subResult.sessionFolderMap) {
sessionFolderMap.set(sessionId, folderPath);
}
}
}
} catch (error) {
const errorStr = String(error);
if (
!errorStr.includes("No such file or directory") &&
!errorStr.includes("ENOENT") &&
!errorStr.includes("not found")
) {
console.error("[FolderPersister] scan error:", error);
}
}

return { folders, sessionFolderMap };
}

export function jsonToContent<Schemas extends OptionalSchemas>(
data: FoldersJson,
): Content<Schemas> {
return [{ folders: data }, {}] as unknown as Content<Schemas>;
}

export function createFolderPersister<Schemas extends OptionalSchemas>(
store: MergeableStore<Schemas>,
config: { mode: PersisterMode } = { mode: "load-only" },
) {
const loadFn =
config.mode === "save-only"
? async (): Promise<Content<Schemas> | undefined> => undefined
: async (): Promise<Content<Schemas> | undefined> => {
try {
const sessionsDir = await getSessionsDir();
const dirExists = await exists(sessionsDir);

if (!dirExists) {
return jsonToContent<Schemas>({});
}

const { folders, sessionFolderMap } =
await scanDirectoryRecursively(sessionsDir);

for (const [sessionId, folderPath] of sessionFolderMap) {
// @ts-ignore - we're setting cells on the sessions table
if (store.hasRow("sessions", sessionId)) {
// @ts-ignore
store.setCell("sessions", sessionId, "folder_id", folderPath);
}
}

return jsonToContent<Schemas>(folders);
} catch (error) {
console.error("[FolderPersister] load error:", error);
return undefined;
}
};

const saveFn = async () => {};

return createCustomPersister(
store,
loadFn,
saveFn,
(listener) => setInterval(listener, 5000),
(handle) => clearInterval(handle),
(error) => console.error("[FolderPersister]:", error),
StoreOrMergeableStore,
);
}

interface Loadable {
load(): Promise<unknown>;
}

export async function startFolderWatcher(
persister: Loadable,
): Promise<() => void> {
const result = await notifyCommands.start();
if (result.status === "error") {
console.error("[FolderWatcher] Failed to start:", result.error);
return () => {};
}

const unlisten = await notifyEvents.fileChanged.listen(async (event) => {
const path = event.payload.path;
if (path.startsWith("sessions/")) {
try {
await persister.load();
} catch (error) {
console.error("[FolderWatcher] Failed to reload:", error);
}
}
});

return () => {
unlisten();
void notifyCommands.stop();
};
}
Loading
Loading