From 8909b657834f6db75e0083b74b645bc17ae9b139 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Mon, 27 Jul 2026 20:21:14 +0530 Subject: [PATCH 1/7] feat(app): add restart command and reload workspace - Add Restart Notely in File menu and Command Palette - Protect unsaved changes with confirmation prompt before restart/switch - Store favorites in workspace .notes-app/metadata.json without path or .md - Restart background AI worker process and refresh Git/AI state on switch --- electron/lib/core/appMenu.cjs | 8 +++ electron/lib/core/workspaceMetadata.cjs | 18 +++++- electron/lib/ipc/coreIpc.cjs | 59 ++++++++++++++++++ electron/main.cjs | 33 +++++++++- electron/preload.cjs | 24 +++++++ src/App.jsx | 83 ++++++++++++++++++++++--- src/components/DashboardPanels.jsx | 19 +++--- src/components/NoteListPanel.jsx | 11 +++- src/hooks/useAIAssistant.js | 7 +++ src/hooks/useDocumentManager.js | 48 +++++++++++++- src/hooks/useWorkspaceScopedStorage.js | 3 +- 11 files changed, 287 insertions(+), 26 deletions(-) diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index 91403ee..0063038 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -127,6 +127,10 @@ function buildAppMenuTemplate(win, context = {}) { click: () => sendMenuAction(win, "back-to-notes") }, { type: "separator" }, + { + label: "Restart Notely", + click: () => sendMenuAction(win, "restart-app") + }, { role: "quit" } ] : [ @@ -162,6 +166,10 @@ function buildAppMenuTemplate(win, context = {}) { click: () => sendMenuAction(win, "remove-folder") }, { type: "separator" }, + { + label: "Restart Notely", + click: () => sendMenuAction(win, "restart-app") + }, { role: "quit" } ]; diff --git a/electron/lib/core/workspaceMetadata.cjs b/electron/lib/core/workspaceMetadata.cjs index 30e8363..f53e18d 100644 --- a/electron/lib/core/workspaceMetadata.cjs +++ b/electron/lib/core/workspaceMetadata.cjs @@ -26,11 +26,13 @@ class WorkspaceMetadata { if (this.fs.existsSync(p)) { this.state = JSON.parse(this.fs.readFileSync(p, "utf8")); } else { - this.state = { items: {} }; + this.state = { items: {}, favorites: [] }; } } catch { - this.state = { items: {} }; + this.state = { items: {}, favorites: [] }; } + if (!this.state.items) this.state.items = {}; + if (!Array.isArray(this.state.favorites)) this.state.favorites = []; } _save() { @@ -67,6 +69,18 @@ class WorkspaceMetadata { return this.state?.items || {}; } + getFavorites() { + this._load(); + return Array.isArray(this.state?.favorites) ? this.state.favorites : []; + } + + setFavorites(favoritesList) { + this._load(); + this.state.favorites = Array.isArray(favoritesList) ? favoritesList : []; + this._save(); + return this.state.favorites; + } + updateMetadata(absolutePath, { icon, color }) { this._load(); const relPath = this._getRelativePath(absolutePath); diff --git a/electron/lib/ipc/coreIpc.cjs b/electron/lib/ipc/coreIpc.cjs index f0d0d7d..ce5edfd 100644 --- a/electron/lib/ipc/coreIpc.cjs +++ b/electron/lib/ipc/coreIpc.cjs @@ -542,6 +542,65 @@ function registerCoreIpcHandlers(ipcMain, deps) { return { success: false }; }); + registerTrustedHandler("app:restart", (event) => { + if (!app.isPackaged) { + console.log("\n======================================================="); + console.log("[Notely] Restarting Application & Re-initializing Services (Dev Mode)"); + console.log("=======================================================\n"); + + if (typeof applyNotesRoot === "function" && typeof getNotesRoot === "function") { + const root = getNotesRoot(); + if (root) { + try { + applyNotesRoot(root); + } catch (err) { + console.error("[Restart] Service re-initialization error:", err); + } + } + } + + const win = event?.sender ? BrowserWindow.fromWebContents(event.sender) : null; + if (win && !win.isDestroyed()) { + win.webContents.reload(); + } else { + const windows = BrowserWindow.getAllWindows(); + for (const w of windows) { + if (w && !w.isDestroyed()) { + w.webContents.reload(); + } + } + } + } else { + app.relaunch(); + app.exit(0); + } + return { success: true }; + }); + + registerTrustedHandler("workspace-metadata:get-favorites", () => { + if (typeof getWorkspaceMetadataStore === "function") { + const store = getWorkspaceMetadataStore(); + if (store) return store.getFavorites(); + } + return []; + }); + + registerTrustedHandler("workspace-metadata:set-favorites", (_event, payload) => { + const { favorites } = payload || {}; + if (typeof getWorkspaceMetadataStore === "function") { + const store = getWorkspaceMetadataStore(); + if (store) { + const nextFavorites = store.setFavorites(favorites); + for (const win of BrowserWindow.getAllWindows()) { + if (!win || win.isDestroyed()) continue; + win.webContents.send("workspace-metadata:favorites-changed", nextFavorites); + } + return { success: true, favorites: nextFavorites }; + } + } + return { success: false, favorites: [] }; + }); + registerTrustedHandler("shell:open-external", async (_event, payload) => { if (typeof payload?.url === "string" && /^https?:\/\//i.test(payload.url)) { await shell.openExternal(payload.url); diff --git a/electron/main.cjs b/electron/main.cjs index 45cca86..4b67e2a 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -469,11 +469,38 @@ function applyNotesRoot(nextRootPath) { console.warn("[startup] Unable to clean up legacy thumbnails folder:", error?.message || error); } - if (previousNotesRoot && previousNotesRoot !== notesRoot) { - console.log(`[Workspace Switch] Re-initializing AI subsystem for new workspace root: ${notesRoot}`); - initializeAIForWorkspace().catch((err) => { + if (previousNotesRoot) { + console.log(`[App Service Reset] Re-initializing AI subsystem for workspace root: ${notesRoot}`); + try { + const workerManager = require("./ai/workerManager.cjs"); + workerManager.shutdownWorker(); + } catch (e) { + console.warn("[Workspace Switch] Failed to shutdown AI worker manager:", e?.message || e); + } + + initializeAIForWorkspace().then(() => { + try { + const workerManager = require("./ai/workerManager.cjs"); + const AIConfig = require("../ai/core/AIConfig"); + const config = new AIConfig(); + const hfToken = config.getAPIKey("huggingface"); + workerManager.startWorker(notesRoot, appDataDir, hfToken); + } catch (err) { + console.warn("[Workspace Switch] Failed to restart AI worker process:", err?.message || err); + } + }).catch((err) => { console.error("[Workspace Switch] Failed to re-initialize AI subsystem:", err?.message || err); }); + + try { + const { BrowserWindow } = require("electron"); + for (const win of BrowserWindow.getAllWindows()) { + if (!win || win.isDestroyed()) continue; + win.webContents.send("workspace:changed", { notesRoot }); + } + } catch (e) { + console.warn("[Workspace Switch] Failed to broadcast workspace:changed:", e?.message || e); + } } metadataStore = createMetadataStore({ diff --git a/electron/preload.cjs b/electron/preload.cjs index 8cfa635..4fbb5ca 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -10,6 +10,15 @@ contextBridge.exposeInMainWorld("notesApi", { ipcRenderer.on("app-menu:action", listener); return () => ipcRenderer.removeListener("app-menu:action", listener); }, + onAppMenuAction: (callback) => { + if (typeof callback !== "function") { + return () => {}; + } + + const listener = (_event, action) => callback(action); + ipcRenderer.on("app-menu:action", listener); + return () => ipcRenderer.removeListener("app-menu:action", listener); + }, notifyBootReady: () => ipcRenderer.send("app:boot-ready"), notifyBootProgress: (payload) => ipcRenderer.send("app:boot-progress", payload), updateMenuContext: (payload) => ipcRenderer.send("app-menu:update-context", payload), @@ -312,6 +321,21 @@ contextBridge.exposeInMainWorld("notesApi", { ipcRenderer.on("workspace-metadata:changed", listener); return () => ipcRenderer.removeListener("workspace-metadata:changed", listener); }, + getFavorites: () => ipcRenderer.invoke("workspace-metadata:get-favorites"), + setFavorites: (favorites) => ipcRenderer.invoke("workspace-metadata:set-favorites", { favorites }), + onFavoritesChanged: (callback) => { + if (typeof callback !== "function") return () => {}; + const listener = (_event, payload) => callback(payload); + ipcRenderer.on("workspace-metadata:favorites-changed", listener); + return () => ipcRenderer.removeListener("workspace-metadata:favorites-changed", listener); + }, + onWorkspaceChanged: (callback) => { + if (typeof callback !== "function") return () => {}; + const listener = (_event, payload) => callback(payload); + ipcRenderer.on("workspace:changed", listener); + return () => ipcRenderer.removeListener("workspace:changed", listener); + }, + restartApp: () => ipcRenderer.invoke("app:restart"), exportNotePackage: (payload) => ipcRenderer.invoke("note-package:export", payload), importNotePackage: (payload) => ipcRenderer.invoke("note-package:import", payload), browseExportDestination: (payload) => ipcRenderer.invoke("note-package:browse-export-destination", payload), diff --git a/src/App.jsx b/src/App.jsx index 043aa98..593c4b8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -291,6 +291,13 @@ function normalizePathLikeList(entries) { return normalized; } +function getCleanFilenameTitle(titleOrPath) { + const raw = String(titleOrPath || "").trim(); + if (!raw) return "Untitled"; + const baseName = raw.split(/[\\/]/).pop() || raw; + return baseName.replace(/\.(md|markdown)$/i, ""); +} + function getFavoriteDashboardNotes(favorites, recentNotes, continueNotes) { const favoriteSet = new Set(Array.isArray(favorites) ? favorites : []); const metadataMap = new Map( @@ -303,9 +310,10 @@ function getFavoriteDashboardNotes(favorites, recentNotes, continueNotes) { .map((filePath) => { const key = String(filePath || "").toLowerCase(); const item = metadataMap.get(key) || { filePath, title: filePath, entryType: "file" }; + const rawTitle = item.title || filePath; return { ...item, - displayName: item.title || filePath, + displayName: getCleanFilenameTitle(rawTitle), }; }) .filter((item) => item?.filePath) @@ -450,6 +458,7 @@ export default function App() { handleCreateFolder, handleOpenWorkspacePicker, handleOpenRecentWorkspace, + handleRestartApp, handleGoHome, handleOpenCurrentInEditor, handleOpenWebsiteFromLanding, @@ -605,13 +614,45 @@ export default function App() { normalize: normalizeDensityMode, fallbackKey: "notes:density-mode", }); - const [favoriteNotes, setFavoriteNotes] = useWorkspaceScopedStorage({ - workspaceScope: workspaceStorageScope, - key: "notes:favorites", - defaultValue: EMPTY_ARRAY, - normalize: normalizeFavoriteNotes, - fallbackKey: "notes:favorites", - }); + const [favoriteNotes, setFavoriteNotesState] = useState(EMPTY_ARRAY); + + const loadWorkspaceFavorites = useCallback(async () => { + try { + const api = window.electronAPI || window.notesApi; + if (api?.getFavorites) { + const favs = await api.getFavorites(); + setFavoriteNotesState(normalizeFavoriteNotes(favs)); + return; + } + } catch { + // Fallback + } + }, []); + + const setFavoriteNotes = useCallback((nextValueOrFn) => { + setFavoriteNotesState((current) => { + const next = typeof nextValueOrFn === "function" ? nextValueOrFn(current) : nextValueOrFn; + const normalized = normalizeFavoriteNotes(next); + const api = window.electronAPI || window.notesApi; + if (api?.setFavorites) { + api.setFavorites(normalized).catch(() => {}); + } + return normalized; + }); + }, []); + + useEffect(() => { + void loadWorkspaceFavorites(); + }, [workspaceStorageScope, notesFolderPath, loadWorkspaceFavorites]); + + useEffect(() => { + const api = window.electronAPI || window.notesApi; + if (!api?.onFavoritesChanged) return undefined; + return api.onFavoritesChanged((favs) => { + setFavoriteNotesState(normalizeFavoriteNotes(favs)); + }); + }, []); + const [showTerminal, setShowTerminal] = useWorkspaceScopedStorage({ workspaceScope: workspaceStorageScope, key: "notes:terminal-open", @@ -1011,6 +1052,26 @@ export default function App() { void refreshGitWorkspaceMeta(); }, [notesFolderPath, currentFilePath, dirty, refreshGitWorkspaceMeta]); + useEffect(() => { + const api = window.electronAPI || window.notesApi; + const subscribe = api?.onMenuAction || api?.onAppMenuAction; + if (!subscribe) return undefined; + return subscribe((action) => { + if (action === "restart-app") { + void handleRestartApp(); + } + }); + }, [handleRestartApp]); + + useEffect(() => { + const api = window.electronAPI || window.notesApi; + if (!api?.onWorkspaceChanged) return undefined; + return api.onWorkspaceChanged(() => { + void refreshGitWorkspaceMeta(); + void loadDocumentsData(); + }); + }, [refreshGitWorkspaceMeta, loadDocumentsData]); + useEffect(() => { function onGlobalKeyDown(event) { const isCmdK = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; @@ -1982,6 +2043,7 @@ export default function App() { } const paletteCommandsBase = [ + { id: "restart-app", label: "Restart Notely", group: "App", aliases: "restart relaunch reboot app application" }, { id: "new-note", label: "Create New Note", group: "Notes", shortcut: "Ctrl/Cmd+N", aliases: "add note new document write jot capture" }, { id: "open-ai-palette", label: "Open AI Palette", group: "AI", shortcut: "Ctrl/Cmd+Shift+I", aliases: "assistant ask ai prompt summarize rewrite" }, { id: "open-help-center", label: "Open Help Center", group: "Help", shortcut: "F1", aliases: "help docs guide manual about" }, @@ -2334,6 +2396,11 @@ export default function App() { return; } + if (resolvedCommandId === "restart-app") { + await handleRestartApp(); + return; + } + if (resolvedCommandId === "open-workspace") { await handleOpenWorkspacePicker(); return; diff --git a/src/components/DashboardPanels.jsx b/src/components/DashboardPanels.jsx index 3c35d1a..8c84520 100644 --- a/src/components/DashboardPanels.jsx +++ b/src/components/DashboardPanels.jsx @@ -15,14 +15,15 @@ function getRecentNotes(documents) { }); } +function getCleanFilename(filePathOrTitle) { + if (!filePathOrTitle) return "Untitled"; + const raw = String(filePathOrTitle).replace(/\\/g, "/").trim(); + const fileName = raw.split("/").filter(Boolean).pop() || raw; + return fileName.replace(/\.(md|markdown)$/i, ""); +} + function getDisplayName(filePath) { - if (!filePath) return "Untitled"; - const normalizedPath = String(filePath).replace(/\\/g, "/"); - const parts = normalizedPath.split("/").filter(Boolean); - if (parts.length === 1) { - return parts[0]; - } - return parts.slice(-2).join("/"); + return getCleanFilename(filePath); } export function DashboardPanels({ documents, taskDocuments = documents, loading, onOpen, onOpenTask, onOpenAllTasks, onOpenRecentNotes, onOpenFavorites, onAction, continueNotes = [], favorites = [], layout = "bar" }) { @@ -178,7 +179,7 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, data-tooltip={`Last edited: ${formatDate(note.updatedAt)}`} > - {note.displayName} + {getCleanFilename(note.displayName || note.title || note.filePath)} @@ -323,7 +324,7 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, {visibleFavorites.map((note) => (
  • diff --git a/src/components/NoteListPanel.jsx b/src/components/NoteListPanel.jsx index 96d32f5..4027a2f 100644 --- a/src/components/NoteListPanel.jsx +++ b/src/components/NoteListPanel.jsx @@ -7,6 +7,13 @@ function getIcon(type) { return type === "favorites" ? : ; } +function formatNoteTitle(note) { + const raw = String(note?.displayName || note?.title || note?.filePath || "").trim(); + if (!raw) return "Untitled"; + const baseName = raw.split(/[\\/]/).pop() || raw; + return baseName.replace(/\.(md|markdown)$/i, ""); +} + export function NoteListPanel({ isOpen, title, @@ -22,7 +29,7 @@ export function NoteListPanel({ const needle = filter.trim().toLowerCase(); if (!needle) return notes; return notes.filter((note) => { - const titleText = String(note?.displayName || note?.title || "").toLowerCase(); + const titleText = formatNoteTitle(note).toLowerCase(); const pathText = String(note?.filePath || "").toLowerCase(); return titleText.includes(needle) || pathText.includes(needle); }); @@ -76,7 +83,7 @@ export function NoteListPanel({ onClose?.(); }} > - {note.displayName || note.title} + {formatNoteTitle(note)} {formatDate(note.updatedAt)} diff --git a/src/hooks/useAIAssistant.js b/src/hooks/useAIAssistant.js index e707833..3d932f4 100644 --- a/src/hooks/useAIAssistant.js +++ b/src/hooks/useAIAssistant.js @@ -535,6 +535,13 @@ export function useAIAssistant({ } }, [aiPanelVisible]); + useEffect(() => { + setAiChatMessages([]); + currentConversationIdRef.current = null; + loadConversations(); + refreshAIConfiguration(); + }, [notesFolderPath, loadConversations]); + useEffect(() => { setInlineGhostSuggestion(null); // Clearing the chat messages array on document or tab switch guarantees diff --git a/src/hooks/useDocumentManager.js b/src/hooks/useDocumentManager.js index 49ac9aa..8f67215 100644 --- a/src/hooks/useDocumentManager.js +++ b/src/hooks/useDocumentManager.js @@ -784,7 +784,6 @@ export function useDocumentManager({ notify }) { const selectedPath = await pickFolder(); if (!selectedPath) return; const normalizedSelectedPath = normalizePathValue(selectedPath); - setNotesFolderPath(normalizedSelectedPath); await handleSaveNotesFolder(normalizedSelectedPath); } catch (err) { notify(err?.message || "Unable to open folder picker.", "error"); @@ -798,6 +797,24 @@ export function useDocumentManager({ notify }) { return; } + const dirtyPaths = []; + for (const path of openTabs) { + const state = tabStatesRef.current[path]; + if (state && state.doc && state.doc.savedHash !== state.savedHash) { + dirtyPaths.push(path); + } + } + if (dirtyPaths.length > 0) { + const confirmed = await confirm({ + title: "Unsaved Changes", + message: `There are ${dirtyPaths.length} note(s) with unsaved changes. Switch workspace and discard changes?`, + confirmLabel: "Discard & Switch", + cancelLabel: "Cancel", + variant: "danger" + }); + if (!confirmed) return; + } + setSavingNotesFolder(true); try { const result = await setNotesRootSetting(nextPath); @@ -819,6 +836,34 @@ export function useDocumentManager({ notify }) { } } + async function handleRestartApp() { + const dirtyPaths = []; + for (const path of openTabs) { + const state = tabStatesRef.current[path]; + if (state && state.doc && state.doc.savedHash !== state.savedHash) { + dirtyPaths.push(path); + } + } + if (dirtyPaths.length > 0) { + const confirmed = await confirm({ + title: "Unsaved Changes", + message: `There are ${dirtyPaths.length} note(s) with unsaved changes. Restart Notely and discard changes?`, + confirmLabel: "Discard & Restart", + cancelLabel: "Cancel", + variant: "danger" + }); + if (!confirmed) return; + } + + if (window.electronAPI?.restartApp) { + await window.electronAPI.restartApp(); + } else if (window.notesApi?.restartApp) { + await window.notesApi.restartApp(); + } else { + window.location.reload(); + } + } + async function handleOpenRecentWorkspace(workspacePath) { const nextPath = normalizePathValue(workspacePath); if (!nextPath) return; @@ -1254,6 +1299,7 @@ export function useDocumentManager({ notify }) { handleOpenWorkspacePicker, handleSaveNotesFolder, handleOpenRecentWorkspace, + handleRestartApp, handleGoHome, handleOpenCurrentInEditor, handleOpenWebsiteFromLanding, diff --git a/src/hooks/useWorkspaceScopedStorage.js b/src/hooks/useWorkspaceScopedStorage.js index c5058e5..babd551 100644 --- a/src/hooks/useWorkspaceScopedStorage.js +++ b/src/hooks/useWorkspaceScopedStorage.js @@ -27,7 +27,8 @@ export function useWorkspaceScopedStorage({ try { const scopedKey = `${key}:${workspaceScope}`; const scopedRaw = window.localStorage.getItem(scopedKey); - const fallbackRaw = fallbackKey ? window.localStorage.getItem(fallbackKey) : null; + const allowFallback = Boolean(fallbackKey) && (!workspaceScope || workspaceScope === "default"); + const fallbackRaw = allowFallback ? window.localStorage.getItem(fallbackKey) : null; const parsed = JSON.parse(scopedRaw ?? fallbackRaw ?? JSON.stringify(defaultValueRef.current)); setValue(normalizeRef.current(parsed)); } catch { From 18fdc8d8bd2a44605f601d810f76d0ce85cc823d Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Mon, 27 Jul 2026 20:24:10 +0530 Subject: [PATCH 2/7] Fixed Quite !! --- electron/lib/core/appMenu.cjs | 6 +++--- electron/main.cjs | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index 0063038..60bdcb5 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -1,4 +1,4 @@ -const { Menu } = require("electron"); +const { Menu, app } = require("electron"); // Sends a menu-action signal to the renderer for the given window. function sendMenuAction(win, action) { @@ -131,7 +131,7 @@ function buildAppMenuTemplate(win, context = {}) { label: "Restart Notely", click: () => sendMenuAction(win, "restart-app") }, - { role: "quit" } + { role: "quit", click: () => app.quit() } ] : [ { @@ -170,7 +170,7 @@ function buildAppMenuTemplate(win, context = {}) { label: "Restart Notely", click: () => sendMenuAction(win, "restart-app") }, - { role: "quit" } + { role: "quit", click: () => app.quit() } ]; const editSubmenu = [ diff --git a/electron/main.cjs b/electron/main.cjs index 4b67e2a..bddc02c 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1117,7 +1117,9 @@ ipcMain.on("window:execute-menu-item", (event, { indexPath, role, action }) => { } if (role) { - if (role === "toggleDevTools") { + if (role === "quit") { + app.quit(); + } else if (role === "toggleDevTools") { win.webContents.toggleDevTools(); } else if (role === "reload") { win.webContents.reload(); From c54b6ebb408e694bf42f0c3eba553724b85f4144 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Mon, 27 Jul 2026 20:45:58 +0530 Subject: [PATCH 3/7] feat(ui): add menu icons and fan-out New submenu - Replace File > New Note with New > Note, Folder submenu - Add theme-aware Lucide icons to top bar dropdown menu items - Move Assets Library and Trash / Removed Items to Workspace menu - Streamline Quick Actions rail panel and right-align list count - Fix Quit menu item IPC handler in main process --- electron/lib/core/appMenu.cjs | 38 ++++++- src/App.jsx | 15 +++ src/components/DashboardPanels.jsx | 8 +- src/components/LandingListControls.jsx | 29 +---- src/components/layout/TitleBar.jsx | 147 ++++++++++++++++++++++++- src/styles/layout.css | 3 +- src/styles/titlebar.css | 11 ++ 7 files changed, 207 insertions(+), 44 deletions(-) diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index 60bdcb5..5eab185 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -69,9 +69,18 @@ function buildAppMenuTemplate(win, context = {}) { const fileSubmenu = screen === "document" ? [ { - label: "New Note", - accelerator: "CmdOrCtrl+N", - click: () => sendMenuAction(win, "new-note") + label: "New", + submenu: [ + { + label: "Note", + accelerator: "CmdOrCtrl+N", + click: () => sendMenuAction(win, "new-note") + }, + { + label: "Folder", + click: () => sendMenuAction(win, "new-folder") + } + ] }, { label: "Open Workspace", @@ -135,9 +144,18 @@ function buildAppMenuTemplate(win, context = {}) { ] : [ { - label: "New Note", - accelerator: "CmdOrCtrl+N", - click: () => sendMenuAction(win, "new-note") + label: "New", + submenu: [ + { + label: "Note", + accelerator: "CmdOrCtrl+N", + click: () => sendMenuAction(win, "new-note") + }, + { + label: "Folder", + click: () => sendMenuAction(win, "new-folder") + } + ] }, { label: "Open Workspace", @@ -517,6 +535,14 @@ function buildAppMenuTemplate(win, context = {}) { accelerator: "CmdOrCtrl+Shift+A", click: () => sendMenuAction(win, "open-workspace-activity") }, + { + label: "Assets Library", + click: () => sendMenuAction(win, "open-assets") + }, + { + label: "Trash / Removed Items", + click: () => sendMenuAction(win, "open-trash") + }, { label: "Reload Workspace", accelerator: "CmdOrCtrl+Alt+R", diff --git a/src/App.jsx b/src/App.jsx index 593c4b8..3404474 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1414,6 +1414,16 @@ export default function App() { return; } + if (action === "open-assets") { + setLandingAssetsOpen(true); + return; + } + + if (action === "open-trash") { + setTrashDialogOpen(true); + return; + } + if (action.startsWith("open-recent-workspace:")) { const encodedPath = String(action).slice("open-recent-workspace:".length); const workspacePath = decodeURIComponent(encodedPath || ""); @@ -2599,6 +2609,11 @@ export default function App() { return; } + if (action === "reload-workspace" || action === "refresh") { + void handleReloadWorkspace(); + return; + } + if (action === "search") { setGlobalSearchQuery(""); setGlobalSearchOpen(true); diff --git a/src/components/DashboardPanels.jsx b/src/components/DashboardPanels.jsx index 8c84520..cea88fe 100644 --- a/src/components/DashboardPanels.jsx +++ b/src/components/DashboardPanels.jsx @@ -1,4 +1,4 @@ -import { ArrowRight, CheckSquare, Clock3, FilePlus2, FolderPlus, Image as ImageIcon, Search, Trash2, FileText, Star, Sparkles } from "lucide-react"; +import { ArrowRight, CheckSquare, Clock3, FilePlus2, FolderPlus, Image as ImageIcon, Search, Trash2, FileText, Star, Sparkles, RefreshCw } from "lucide-react"; import { useMemo } from "react"; import { formatDate } from "../utils/dateUtils"; import { extractOpenTasksFromDocuments, getTaskCountsFromDocuments } from "../utils/taskUtils"; @@ -104,12 +104,6 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, - - diff --git a/src/components/LandingListControls.jsx b/src/components/LandingListControls.jsx index eceaea9..1eddbe4 100644 --- a/src/components/LandingListControls.jsx +++ b/src/components/LandingListControls.jsx @@ -1,5 +1,4 @@ -import { FilePlus2, FileText, Folder, RefreshCw } from "lucide-react"; -import AppButton from "./AppButton"; +import { FileText, Folder } from "lucide-react"; import AppSelect from "./AppSelect"; export function LandingListControls({ @@ -91,32 +90,6 @@ export function LandingListControls({ Notes - - {onReloadWorkspace && ( - - - Reload - - )} - - {onCreateNote && ( - - - New Note - - )} ); } diff --git a/src/components/layout/TitleBar.jsx b/src/components/layout/TitleBar.jsx index ed2f5b3..b9484ea 100644 --- a/src/components/layout/TitleBar.jsx +++ b/src/components/layout/TitleBar.jsx @@ -1,7 +1,150 @@ import React, { useState, useEffect, useRef } from "react"; -import { Minus, Square, Copy, X, Check, ChevronRight, Globe } from "lucide-react"; +import { + Minus, Square, Copy, X, Check, ChevronRight, Globe, + FilePlus, FolderPlus, FolderOpen, Clock, Save, RefreshCw, Package, Edit2, Trash2, ArrowLeft, RotateCcw, Power, + Undo2, Redo2, Scissors, Clipboard, CheckSquare, Search, Replace, Camera, BookOpen, Command, + SunMoon, SpellCheck, Palette, Layout, Columns, Maximize2, ZoomIn, ZoomOut, Minimize2, Code, + Activity, ExternalLink, FolderSearch, GitBranch, GitCommit, History, GitCompare, ArrowUpRight, + ArrowDownLeft, Network, ShieldAlert, KeyRound, Sparkles, Bot, Brain, Cpu, UserCheck, Stethoscope, + HelpCircle, Book, Keyboard, MessageSquareWarning, FileTerminal, Info, FileText, Table, Eye, Image as ImageIcon +} from "lucide-react"; import notelyMark from "../../assets/branding/notely-mark.png"; +const MENU_ICON_MAP = { + "new": FilePlus, + "new note": FilePlus, + "note": FileText, + "folder": FolderPlus, + "open workspace": FolderOpen, + "open recent": Clock, + "save": Save, + "save*": Save, + "auto save": RefreshCw, + "export pdf": FileText, + "export/import note package": Package, + "rename note": Edit2, + "reload from disk": RefreshCw, + "reload workspace from disk": RefreshCw, + "move note to removed": Trash2, + "back to notes": ArrowLeft, + "restart notely": RotateCcw, + "quit": Power, + + "undo": Undo2, + "redo": Redo2, + "cut": Scissors, + "copy": Copy, + "paste": Clipboard, + "select all": CheckSquare, + "find": Search, + "find and replace": Replace, + "screen capture options": Camera, + "spelling dictionary": BookOpen, + + "open command palette": Command, + "theme": SunMoon, + "enable typo check": SpellCheck, + "set icon & color": Palette, + "editor layout": Layout, + "show outline": Layout, + "split preview": Columns, + "focus mode": Maximize2, + "sync split scroll": RefreshCw, + "table click behavior": Table, + "preview options": Eye, + "zoom": ZoomIn, + "zoom in": ZoomIn, + "zoom out": ZoomOut, + "reset zoom": Minimize2, + "developer": Code, + "dashboard view": Layout, + "tile notes": Layout, + "table notes": Layout, + + "assets library": ImageIcon, + "workspace activity": Activity, + "reload workspace": RefreshCw, + "open workspace in vs code": ExternalLink, + "reveal workspace in file explorer": FolderSearch, + "export workspace as zip": Package, + "open project website": Globe, + "open current note website view": Globe, + + "open version control": GitBranch, + "commit…": GitCommit, + "history": History, + "diff current note": GitCompare, + "compare versions": GitCompare, + "push": ArrowUpRight, + "pull": ArrowDownLeft, + "fetch": RefreshCw, + "sync (pull then push)": RefreshCw, + "ignore app data in git": GitBranch, + + "p2p status": Activity, + "run sync self-test": CheckSquare, + "conflict center": ShieldAlert, + "rotate workspace keys": KeyRound, + "how sync works": HelpCircle, + + "open ai palette": Sparkles, + "ai settings": Bot, + "knowledge graph": Brain, + "embeddings": Cpu, + "personas": UserCheck, + "diagnostics": Stethoscope, + + "help center": HelpCircle, + "markdown guide": Book, + "commit": GitCommit, + "keyboard shortcuts": Keyboard, + "report bug / feedback": MessageSquareWarning, + "system & application logs": FileTerminal, + "system application logs": FileTerminal, + "check for updates": RefreshCw, + "about notely": Info, +}; + +function getItemIcon(item) { + if (!item) return null; + const rawLabel = String(item.label || "").toLowerCase().replace(/&/g, " ").replace(/\s+/g, " ").trim(); + const cleanLabel = rawLabel.replace(/…/g, "").replace(/\.\.\./g, "").trim(); + const roleKey = String(item.role || "").toLowerCase().trim(); + + let IconComponent = MENU_ICON_MAP[cleanLabel] || MENU_ICON_MAP[rawLabel] || MENU_ICON_MAP[roleKey]; + + if (!IconComponent) { + if (rawLabel.includes("asset")) { + IconComponent = ImageIcon; + } else if (rawLabel.includes("log")) { + IconComponent = FileTerminal; + } else if (rawLabel.includes("move") && rawLabel.includes("removed")) { + IconComponent = Trash2; + } else if (rawLabel.includes("commit")) { + IconComponent = GitCommit; + } else if (rawLabel.includes("workspace") && (rawLabel.includes("remove") || rawLabel.includes("delete"))) { + IconComponent = Trash2; + } else if (rawLabel.includes("workspace")) { + IconComponent = FolderOpen; + } else if (rawLabel.includes("recent")) { + IconComponent = Clock; + } else if (rawLabel.includes("export") || rawLabel.includes("import")) { + IconComponent = Package; + } else if (rawLabel.includes("theme")) { + IconComponent = SunMoon; + } else if (rawLabel.includes("zoom")) { + IconComponent = ZoomIn; + } else if (rawLabel.includes("reload") || rawLabel.includes("refresh")) { + IconComponent = RefreshCw; + } else if (rawLabel.includes("remove") || rawLabel.includes("delete") || rawLabel.includes("trash")) { + IconComponent = Trash2; + } + } + + if (!IconComponent) return null; + return ; +} + export function TitleBar({ title = "Notely", onOpenWebsite }) { const [isMaximized, setIsMaximized] = useState(false); const [menuStructure, setMenuStructure] = useState([]); @@ -170,7 +313,7 @@ export function TitleBar({ title = "Notely", onOpenWebsite }) { }} >
    - {item.checked && } + {item.checked ? : getItemIcon(item)}
    {getLabel(item)} diff --git a/src/styles/layout.css b/src/styles/layout.css index f43b459..b97c654 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -2338,7 +2338,8 @@ .landing-list-search { display: grid; gap: 4px; - flex: 1 1 220px; + flex: 0 1 220px; + max-width: 260px; } .landing-list-select { display: grid; diff --git a/src/styles/titlebar.css b/src/styles/titlebar.css index 9dc8eea..c486943 100644 --- a/src/styles/titlebar.css +++ b/src/styles/titlebar.css @@ -133,6 +133,17 @@ align-items: center; justify-content: center; color: var(--accent-solid); + flex-shrink: 0; +} + +.titlebar-menu-item-icon { + color: var(--text-muted); + opacity: 0.85; +} + +.titlebar-menu-item:hover .titlebar-menu-item-icon { + color: var(--text-strong); + opacity: 1; } .titlebar-menu-item-label { From 681e741c15f3bb741ef99bf1372ef66eea4a4a94 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Mon, 27 Jul 2026 22:33:45 +0530 Subject: [PATCH 4/7] feat(workspace): add interactive metadata modal and creation workflow - Prompt workspace setup modal when opening uninitialized folder - Add rich metadata form (tags chip list, project type, icon, color) - Support independent README.md creation and Git repository init - Prevent automatic Git init and README creation on workspace open - Fix dark mode headings and confirmation dialog layout Add test suite for workspace creation options. --- electron/lib/core/appMenu.cjs | 14 + electron/lib/core/workspaceMetadata.cjs | 55 ++- electron/lib/git/gitService.cjs | 20 +- electron/lib/ipc/coreIpc.cjs | 122 ++++++- electron/preload.cjs | 10 + src/App.jsx | 133 +++++++- src/components/ConfirmationProvider.jsx | 6 +- src/components/WorkspaceModal.jsx | 391 ++++++++++++++++++++++ src/components/layout/TitleBar.jsx | 2 + src/hooks/useDocumentManager.js | 24 +- src/styles/editor.css | 3 +- src/styles/layout.css | 230 ++++++++++++- src/tests/utils/workspaceCreation.test.js | 139 ++++++++ 13 files changed, 1112 insertions(+), 37 deletions(-) create mode 100644 src/components/WorkspaceModal.jsx create mode 100644 src/tests/utils/workspaceCreation.test.js diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index 5eab185..bf80b74 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -79,6 +79,11 @@ function buildAppMenuTemplate(win, context = {}) { { label: "Folder", click: () => sendMenuAction(win, "new-folder") + }, + { type: "separator" }, + { + label: "Workspace", + click: () => sendMenuAction(win, "new-workspace") } ] }, @@ -154,6 +159,11 @@ function buildAppMenuTemplate(win, context = {}) { { label: "Folder", click: () => sendMenuAction(win, "new-folder") + }, + { type: "separator" }, + { + label: "Workspace", + click: () => sendMenuAction(win, "new-workspace") } ] }, @@ -530,6 +540,10 @@ function buildAppMenuTemplate(win, context = {}) { { label: "Workspace", submenu: [ + { + label: "Workspace Information", + click: () => sendMenuAction(win, "open-workspace-info") + }, { label: "Workspace Activity", accelerator: "CmdOrCtrl+Shift+A", diff --git a/electron/lib/core/workspaceMetadata.cjs b/electron/lib/core/workspaceMetadata.cjs index f53e18d..ac3bf29 100644 --- a/electron/lib/core/workspaceMetadata.cjs +++ b/electron/lib/core/workspaceMetadata.cjs @@ -26,13 +26,26 @@ class WorkspaceMetadata { if (this.fs.existsSync(p)) { this.state = JSON.parse(this.fs.readFileSync(p, "utf8")); } else { - this.state = { items: {}, favorites: [] }; + this.state = { items: {}, favorites: [], info: {} }; } } catch { - this.state = { items: {}, favorites: [] }; + this.state = { items: {}, favorites: [], info: {} }; } if (!this.state.items) this.state.items = {}; if (!Array.isArray(this.state.favorites)) this.state.favorites = []; + if (!this.state.info || typeof this.state.info !== "object") { + const root = this.getNotesRoot(); + const defaultName = root ? this.path.basename(root) : "Workspace"; + this.state.info = { + name: defaultName, + description: "", + domainTags: [], + projectType: "General", + primaryGoal: "", + icon: "", + color: "", + }; + } } _save() { @@ -81,6 +94,32 @@ class WorkspaceMetadata { return this.state.favorites; } + getWorkspaceInfo() { + this._load(); + const root = this.getNotesRoot(); + const defaultName = root ? this.path.basename(root) : "Workspace"; + return { + name: this.state.info?.name || defaultName, + description: this.state.info?.description || "", + domainTags: Array.isArray(this.state.info?.domainTags) ? this.state.info.domainTags : [], + projectType: this.state.info?.projectType || "General", + primaryGoal: this.state.info?.primaryGoal || "", + icon: this.state.info?.icon || "", + color: this.state.info?.color || "", + }; + } + + updateWorkspaceInfo(infoPayload) { + this._load(); + const current = this.getWorkspaceInfo(); + this.state.info = { + ...current, + ...infoPayload, + }; + this._save(); + return this.state.info; + } + updateMetadata(absolutePath, { icon, color }) { this._load(); const relPath = this._getRelativePath(absolutePath); @@ -103,8 +142,18 @@ class WorkspaceMetadata { } } +function validateIsWorkspace(fs, path, dirPath) { + if (!dirPath || typeof dirPath !== "string") return false; + try { + const metaFolder = path.join(dirPath, ".notes-app"); + return fs.existsSync(metaFolder) && fs.statSync(metaFolder).isDirectory(); + } catch { + return false; + } +} + function createWorkspaceMetadata(deps) { return new WorkspaceMetadata(deps); } -module.exports = { createWorkspaceMetadata }; +module.exports = { createWorkspaceMetadata, validateIsWorkspace }; diff --git a/electron/lib/git/gitService.cjs b/electron/lib/git/gitService.cjs index 4eed479..f3921f6 100644 --- a/electron/lib/git/gitService.cjs +++ b/electron/lib/git/gitService.cjs @@ -111,17 +111,6 @@ async function initRepo(workspacePath) { await g.init(); - // README - const readmePath = path.join(workspacePath, "README.md"); - if (!fs.existsSync(readmePath)) { - const name = path.basename(workspacePath); - fs.writeFileSync( - readmePath, - `# ${name}\n\nNotes workspace managed by [Notely](https://github.com/WGLabz/notely).\n`, - "utf8" - ); - } - // .gitignore const gitignorePath = path.join(workspacePath, ".gitignore"); if (!fs.existsSync(gitignorePath)) { @@ -1049,13 +1038,10 @@ async function migrateFromLegacy(workspacePath, metadataStore = null) { return ok({ alreadyMigrated: false, migrated: 0, skipped: 0 }); } - // Ensure we have a git repo to commit into + // Check if workspace is a git repository const repoInfo = await getRepoInfo(workspacePath); - if (!repoInfo.ok) return repoInfo; - - if (!repoInfo.data.isRepo) { - const initResult = await initRepo(workspacePath); - if (!initResult.ok) return initResult; + if (!repoInfo.ok || !repoInfo.data.isRepo) { + return ok({ alreadyMigrated: false, migrated: 0, skipped: 0 }); } const repoRoot = repoInfo.data.repoRoot || (await findRepoRoot(workspacePath)); diff --git a/electron/lib/ipc/coreIpc.cjs b/electron/lib/ipc/coreIpc.cjs index ce5edfd..839e99e 100644 --- a/electron/lib/ipc/coreIpc.cjs +++ b/electron/lib/ipc/coreIpc.cjs @@ -598,7 +598,127 @@ function registerCoreIpcHandlers(ipcMain, deps) { return { success: true, favorites: nextFavorites }; } } - return { success: false, favorites: [] }; + return { success: false }; + }); + + registerTrustedHandler("workspace:validate", (_event, payload) => { + const { dirPath } = payload || {}; + const { validateIsWorkspace } = require("../core/workspaceMetadata.cjs"); + return { isWorkspace: validateIsWorkspace(fs, path, dirPath) }; + }); + + registerTrustedHandler("workspace-metadata:get-info", () => { + if (typeof getWorkspaceMetadataStore === "function") { + const store = getWorkspaceMetadataStore(); + if (store) return store.getWorkspaceInfo(); + } + return {}; + }); + + registerTrustedHandler("workspace-metadata:update-info", (_event, payload) => { + if (typeof getWorkspaceMetadataStore === "function") { + const store = getWorkspaceMetadataStore(); + if (store) { + const updated = store.updateWorkspaceInfo(payload || {}); + for (const win of BrowserWindow.getAllWindows()) { + if (!win || win.isDestroyed()) continue; + win.webContents.send("workspace-metadata:info-changed", updated); + } + return { success: true, info: updated }; + } + } + return { success: false }; + }); + + registerTrustedHandler("workspace:create-new", async (_event, payload) => { + const { + name, + parentLocation, + description = "", + domainTags = [], + projectType = "General", + primaryGoal = "", + icon = "", + color = "", + createWelcomeNote = true, + initGit = false, + } = payload || {}; + + if (!name || !name.trim()) { + throw new Error("Workspace name is required."); + } + if (!parentLocation || !parentLocation.trim()) { + throw new Error("Workspace parent location is required."); + } + + const sanitizedName = name.trim().replace(/[\\/:*?"<>|]/g, "_"); + const newWorkspacePath = path.join(parentLocation.trim(), sanitizedName); + const metaAppDir = path.join(newWorkspacePath, ".notes-app"); + + let isNestedWorkspace = false; + let parentWorkspacePath = null; + let curr = path.resolve(parentLocation.trim()); + while (curr) { + const checkMeta = path.join(curr, ".notes-app"); + try { + if (fs.existsSync(checkMeta) && fs.statSync(checkMeta).isDirectory()) { + isNestedWorkspace = true; + parentWorkspacePath = curr; + break; + } + } catch {} + const parentDir = path.dirname(curr); + if (parentDir === curr) break; + curr = parentDir; + } + + ensureDir(newWorkspacePath); + ensureDir(metaAppDir); + + const initialInfo = { + name: name.trim(), + description: description.trim(), + domainTags: Array.isArray(domainTags) ? domainTags : [], + projectType: projectType || "General", + primaryGoal: primaryGoal.trim(), + icon: icon || "", + color: color || "", + }; + + const initialMeta = { + items: {}, + favorites: [], + info: initialInfo, + }; + + const metaJsonPath = path.join(metaAppDir, "metadata.json"); + fs.writeFileSync(metaJsonPath, JSON.stringify(initialMeta, null, 2), "utf8"); + + if (initGit) { + try { + const simpleGit = require("simple-git"); + const git = simpleGit(newWorkspacePath); + await git.init(); + } catch (gitErr) { + console.warn("[Workspace Creation] Git init warning:", gitErr?.message || gitErr); + } + } + + if (createWelcomeNote) { + const readmePath = path.join(newWorkspacePath, "README.md"); + if (!fs.existsSync(readmePath)) { + const readmeContent = `# ${name.trim()}\n\nThis is your Notely workspace.\n\n- **Project Type**: ${initialInfo.projectType}\n- **Description**: ${initialInfo.description || "No description provided."}\n\nStart writing notes or organizing folders!`; + fs.writeFileSync(readmePath, readmeContent, "utf8"); + } + } + + return { + success: true, + workspacePath: newWorkspacePath, + info: initialInfo, + isNestedWorkspace, + parentWorkspacePath, + }; }); registerTrustedHandler("shell:open-external", async (_event, payload) => { diff --git a/electron/preload.cjs b/electron/preload.cjs index 4fbb5ca..67fe3b7 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -335,6 +335,16 @@ contextBridge.exposeInMainWorld("notesApi", { ipcRenderer.on("workspace:changed", listener); return () => ipcRenderer.removeListener("workspace:changed", listener); }, + validateWorkspace: (dirPath) => ipcRenderer.invoke("workspace:validate", { dirPath }), + getWorkspaceInfo: () => ipcRenderer.invoke("workspace-metadata:get-info"), + updateWorkspaceInfo: (payload) => ipcRenderer.invoke("workspace-metadata:update-info", payload), + createNewWorkspace: (payload) => ipcRenderer.invoke("workspace:create-new", payload), + onWorkspaceInfoChanged: (callback) => { + if (typeof callback !== "function") return () => {}; + const listener = (_event, payload) => callback(payload); + ipcRenderer.on("workspace-metadata:info-changed", listener); + return () => ipcRenderer.removeListener("workspace-metadata:info-changed", listener); + }, restartApp: () => ipcRenderer.invoke("app:restart"), exportNotePackage: (payload) => ipcRenderer.invoke("note-package:export", payload), importNotePackage: (payload) => ipcRenderer.invoke("note-package:import", payload), diff --git a/src/App.jsx b/src/App.jsx index 3404474..2aa950f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -26,6 +26,7 @@ const AIHealthPage = lazy(() => import("./components/AIHealthPage")); const AppLogsPage = lazy(() => import("./components/AppLogsPage")); import { SettingsModal } from "./components/SettingsModal"; +import { WorkspaceModal } from "./components/WorkspaceModal"; import { LandingView } from "./components/layout/LandingView"; import { TitleBar } from "./components/layout/TitleBar"; import { TrashDialog } from "./components/TrashDialog"; @@ -411,6 +412,30 @@ export default function App() { const [updateStatus, setUpdateStatus] = useState(null); const [updateDetails, setUpdateDetails] = useState(null); const [showUpdateModal, setShowUpdateModal] = useState(false); + + const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false); + const [workspaceModalMode, setWorkspaceModalMode] = useState("create"); + const [workspaceInfoState, setWorkspaceInfoState] = useState({}); + + const handleRequireWorkspaceInitialization = useCallback((folderPath) => { + const normalized = String(folderPath || "").replace(/\\/g, "/").replace(/\/$/, ""); + const lastSlash = normalized.lastIndexOf("/"); + const name = lastSlash !== -1 ? normalized.slice(lastSlash + 1) : normalized || "Workspace"; + const parentLocation = lastSlash !== -1 ? normalized.slice(0, lastSlash) : normalized; + + setWorkspaceInfoState({ + name, + parentLocation, + description: "", + domainTags: [], + projectType: "General", + icon: "πŸ“", + color: "#3b82f6" + }); + setWorkspaceModalMode("create"); + setWorkspaceModalOpen(true); + }, []); + const landingLayoutRef = useRef(null); const { @@ -458,6 +483,7 @@ export default function App() { handleCreateFolder, handleOpenWorkspacePicker, handleOpenRecentWorkspace, + handleSaveNotesFolder, handleRestartApp, handleGoHome, handleOpenCurrentInEditor, @@ -484,7 +510,7 @@ export default function App() { setSelectedParentFolder, initialLine, setInitialLine, - } = useDocumentManager({ notify }); + } = useDocumentManager({ notify, onRequireWorkspaceInitialization: handleRequireWorkspaceInitialization }); const handleCopyLinkPath = useCallback((target) => { const filePath = typeof target === "object" ? target?.filePath : target; @@ -647,12 +673,77 @@ export default function App() { useEffect(() => { const api = window.electronAPI || window.notesApi; - if (!api?.onFavoritesChanged) return undefined; - return api.onFavoritesChanged((favs) => { - setFavoriteNotesState(normalizeFavoriteNotes(favs)); - }); + if (api?.onFavoritesChanged) { + const unsub = api.onFavoritesChanged((favs) => { + setFavoriteNotesState(normalizeFavoriteNotes(favs)); + }); + return () => unsub(); + } }, []); + useEffect(() => { + if (window.notesApi?.getWorkspaceInfo) { + window.notesApi.getWorkspaceInfo().then((info) => { + if (info) setWorkspaceInfoState(info); + }).catch(() => {}); + } + if (window.notesApi?.onWorkspaceInfoChanged) { + const unsub = window.notesApi.onWorkspaceInfoChanged((updated) => { + if (updated) setWorkspaceInfoState(updated); + }); + return () => unsub(); + } + }, [notesFolderPath]); + + const handleOpenNewWorkspaceModal = () => { + setWorkspaceInfoState({}); + setWorkspaceModalMode("create"); + setWorkspaceModalOpen(true); + }; + + const handleOpenWorkspaceInfoModal = async () => { + if (window.notesApi?.getWorkspaceInfo) { + try { + const info = await window.notesApi.getWorkspaceInfo(); + if (info) setWorkspaceInfoState(info); + } catch {} + } + setWorkspaceModalMode("info"); + setWorkspaceModalOpen(true); + }; + + const handleWorkspaceModalSubmit = async (payload) => { + if (workspaceModalMode === "create") { + if (!window.notesApi?.createNewWorkspace) { + throw new Error("Workspace creation is not supported."); + } + const res = await window.notesApi.createNewWorkspace(payload); + if (res?.success && res?.workspacePath) { + notify(`Workspace "${res.info?.name || payload.name}" created successfully.`, "success"); + if (res.isNestedWorkspace) { + notify(`Note: Created inside an existing parent workspace folder. Nested workspaces operate independently.`, "info"); + } + await handleSaveNotesFolder(res.workspacePath); + } + } else { + if (!window.notesApi?.updateWorkspaceInfo) { + throw new Error("Workspace info update is not supported."); + } + const res = await window.notesApi.updateWorkspaceInfo(payload); + if (res?.success) { + notify("Workspace information updated.", "success"); + setWorkspaceInfoState(res.info); + } + } + }; + + const handlePickWorkspaceParentLocation = async () => { + if (window.notesApi?.pickFolder) { + return await window.notesApi.pickFolder(); + } + return ""; + }; + const [showTerminal, setShowTerminal] = useWorkspaceScopedStorage({ workspaceScope: workspaceStorageScope, key: "notes:terminal-open", @@ -1393,6 +1484,16 @@ export default function App() { return; } + if (action === "new-workspace") { + handleOpenNewWorkspaceModal(); + return; + } + + if (action === "open-workspace-info") { + void handleOpenWorkspaceInfoModal(); + return; + } + if (action === "open-workspace") { void handleOpenWorkspacePicker(); return; @@ -2406,6 +2507,16 @@ export default function App() { return; } + if (resolvedCommandId === "new-workspace") { + handleOpenNewWorkspaceModal(); + return; + } + + if (resolvedCommandId === "open-workspace-info") { + await handleOpenWorkspaceInfoModal(); + return; + } + if (resolvedCommandId === "restart-app") { await handleRestartApp(); return; @@ -3281,6 +3392,18 @@ export default function App() { ) : null} + {workspaceModalOpen ? ( + setWorkspaceModalOpen(false)} + onSubmit={handleWorkspaceModalSubmit} + onPickParentLocation={handlePickWorkspaceParentLocation} + /> + ) : null} + {settingsOpen ? (
    - + {/back/i.test(confirmState.cancelLabel) ? : } {confirmState.cancelLabel} { + if (mode === "create" && parentLocation.trim() && window.notesApi?.validateWorkspace) { + window.notesApi.validateWorkspace(parentLocation.trim()).then((res) => { + if (res?.isWorkspace) { + setParentWarning("Notice: Parent location is an existing Notely workspace. This will create a nested workspace inside it."); + } else { + setParentWarning(""); + } + }).catch(() => setParentWarning("")); + } else { + setParentWarning(""); + } + }, [parentLocation, mode]); + + const wasOpenRef = useRef(false); + + useEffect(() => { + if (isOpen && !wasOpenRef.current) { + setError(""); + setParentWarning(""); + setSubmitting(false); + setTagInput(""); + if (mode === "create") { + setName(initialInfo.name || ""); + setParentLocation(initialInfo.parentLocation || defaultParentLocation || ""); + setDescription(initialInfo.description || ""); + setDomainTags(Array.isArray(initialInfo.domainTags) ? initialInfo.domainTags : []); + setProjectType(initialInfo.projectType || "General"); + setPrimaryGoal(initialInfo.primaryGoal || ""); + setIcon(initialInfo.icon || "πŸ“"); + setColor(initialInfo.color || "#3b82f6"); + setCreateWelcomeNote(initialInfo.createWelcomeNote !== false); + setInitGit(initialInfo.initGit !== false); + } else { + // "info" mode + setName(initialInfo.name || ""); + setDescription(initialInfo.description || ""); + setDomainTags(Array.isArray(initialInfo.domainTags) ? initialInfo.domainTags : []); + setProjectType(initialInfo.projectType || "General"); + setPrimaryGoal(initialInfo.primaryGoal || ""); + setIcon(initialInfo.icon || "πŸ“"); + setColor(initialInfo.color || "#3b82f6"); + } + } + wasOpenRef.current = isOpen; + }, [isOpen, mode, initialInfo, defaultParentLocation]); + + const addTag = (raw) => { + const cleaned = String(raw || "").replace(/^[#\s]+/, "").replace(/\s+/g, "").trim(); + if (!cleaned) return; + if (!domainTags.includes(cleaned)) { + setDomainTags((prev) => [...prev, cleaned]); + } + setTagInput(""); + }; + + const removeTag = (indexToRemove) => { + setDomainTags((prev) => prev.filter((_, i) => i !== indexToRemove)); + }; + + const handleTagKeyDown = (e) => { + if (e.key === "Enter" || e.key === " " || e.key === ",") { + e.preventDefault(); + addTag(tagInput); + } else if (e.key === "Backspace" && !tagInput && domainTags.length > 0) { + removeTag(domainTags.length - 1); + } + }; + + if (!isOpen) return null; + + const handleBrowseLocation = async () => { + if (onPickParentLocation) { + const selected = await onPickParentLocation(); + if (selected) { + setParentLocation(selected); + } + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + + const trimmedName = name.trim(); + if (!trimmedName) { + setError("Workspace name is required."); + return; + } + + if (mode === "create" && !parentLocation.trim()) { + setError("Please select a parent location folder."); + return; + } + + let finalTags = [...domainTags]; + if (tagInput.trim()) { + const cleaned = tagInput.replace(/^[#\s]+/, "").replace(/\s+/g, "").trim(); + if (cleaned && !finalTags.includes(cleaned)) { + finalTags.push(cleaned); + } + } + + if (mode === "create") { + const summaryList = ["β€’ Create .notes-app configuration & metadata"]; + if (createWelcomeNote) summaryList.push("β€’ Create README.md note"); + if (initGit) summaryList.push("β€’ Initialize Git repository (.git)"); + + const confirmed = await confirm({ + title: "Confirm Workspace Setup", + message: `Setting up workspace "${trimmedName}":\n\n${summaryList.join("\n")}\n\nProceed with initialization?`, + confirmLabel: "Initialize", + cancelLabel: "Back to Edit", + variant: "primary" + }); + if (!confirmed) { + return; + } + } + + setSubmitting(true); + try { + if (mode === "create") { + await onSubmit?.({ + name: trimmedName, + parentLocation: parentLocation.trim(), + description: description.trim(), + domainTags: finalTags, + projectType, + primaryGoal: primaryGoal.trim(), + icon, + color, + createWelcomeNote, + initGit, + }); + } else { + await onSubmit?.({ + name: trimmedName, + description: description.trim(), + domainTags: finalTags, + projectType, + primaryGoal: primaryGoal.trim(), + icon, + color, + }); + } + onClose?.(); + } catch (err) { + setError(err?.message || "Failed to save workspace."); + } finally { + setSubmitting(false); + } + }; + + const isCreate = mode === "create"; + + return ( + +
    +
    + {isCreate ? : } +

    {isCreate ? "Create New Workspace" : "Workspace Information"}

    +
    + +
    + +
    + {error &&
    {error}
    } + +
    + + setName(e.target.value)} + placeholder="e.g. Engineering Docs, Personal Study" + required + autoFocus + /> +
    + + {isCreate && ( +
    + +
    + setParentLocation(e.target.value)} + placeholder="Select parent folder path..." + required + /> + + Browse... + +
    + {parentWarning && ( +
    + + {parentWarning} +
    + )} +
    + )} + +
    +
    + + setProjectType(e.target.value)} + > + {PROJECT_TYPES.map((t) => ( + + ))} + +
    + +
    + +
    + {domainTags.map((tag, idx) => ( + + #{tag} + + + ))} + setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + onBlur={() => addTag(tagInput)} + placeholder={domainTags.length === 0 ? "Type tag & press Enter/Space..." : "Add tag..."} + /> +
    +
    +
    + +
    + +