Skip to content
Merged
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
5 changes: 5 additions & 0 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ function createWindow(): void {
mainWindow?.show()
})

// Keep the renderer's maximize/restore icon in sync — covers the toolbar
// button, double-clicking the title bar, and OS window-snap gestures.
mainWindow.on('maximize', () => mainWindow?.webContents.send('window:maximizeChanged', true))
mainWindow.on('unmaximize', () => mainWindow?.webContents.send('window:maximizeChanged', false))

mainWindow.webContents.on('before-input-event', (event, input) => {
const isMacQuitShortcut =
process.platform === 'darwin' &&
Expand Down
1 change: 1 addition & 0 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
win.isMaximized() ? win.restore() : win.maximize()
})
ipcMain.on('window:close', () => getWindow()?.close())
ipcMain.handle('window:isMaximized', () => getWindow()?.isMaximized() ?? false)

// Setup handlers — skipped in dev (uses .venv instead of python-embed)
ipcMain.handle('setup:check', async () => {
Expand Down
11 changes: 8 additions & 3 deletions electron/preload/electron-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra
return {
// Window controls
window: {
minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'),
close: () => ipcRenderer.send('window:close'),
minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'),
close: () => ipcRenderer.send('window:close'),
isMaximized: () => ipcRenderer.invoke('window:isMaximized') as Promise<boolean>,
onMaximizeChange: (cb: (isMaximized: boolean) => void) => {
ipcRenderer.on('window:maximizeChanged', (_event, isMaximized) => cb(isMaximized as boolean))
},
offMaximizeChange: () => ipcRenderer.removeAllListeners('window:maximizeChanged'),
},

// Renderer UI (zoom whole page — scales every px/rem consistently)
Expand Down
926 changes: 840 additions & 86 deletions src/areas/workflows/WorkflowsPage.tsx

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/areas/workflows/nodes/ExtensionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ const TAG_CLS: Record<string, string> = {

// ─── Param control ────────────────────────────────────────────────────────────

const inputCls = 'w-full bg-zinc-800 border border-zinc-700 rounded-lg px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60'
// nodrag — without it, React Flow starts dragging the node on mousedown inside
// these fields, so click-drag text selection (or opening a <select>) moves the
// node instead.
const inputCls = 'nodrag w-full bg-zinc-800 border border-zinc-700 rounded-lg px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60'

function IntInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) {
const [text, setText] = useState(String(value))
Expand Down
24 changes: 20 additions & 4 deletions src/shared/components/layout/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { useEffect, useState } from 'react'
import { useAppStore } from '@shared/stores/appStore'
import MemoryIndicator from './MemoryIndicator'

export default function TopBar(): JSX.Element {
const { patchUpdateReady, platform, showRamIndicator } = useAppStore()
const isMac = platform === 'darwin'

const [isMaximized, setIsMaximized] = useState(false)

useEffect(() => {
window.electron.window.isMaximized().then(setIsMaximized)
window.electron.window.onMaximizeChange(setIsMaximized)
return () => window.electron.window.offMaximizeChange()
}, [])

const handleMinimize = () => window.electron.window.minimize()
const handleMaximize = () => window.electron.window.maximize()
const handleClose = () => window.electron.window.close()
Expand Down Expand Up @@ -64,11 +73,18 @@ export default function TopBar(): JSX.Element {
<button
onClick={handleMaximize}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-zinc-700 text-zinc-400 hover:text-zinc-100 transition-colors"
aria-label="Maximize"
aria-label={isMaximized ? 'Restore' : 'Maximize'}
>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor">
<rect x="0.5" y="0.5" width="8" height="8" />
</svg>
{isMaximized ? (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor">
<rect x="2.5" y="0.5" width="7" height="7" />
<path d="M0.5 2.5v7h7" />
</svg>
) : (
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor">
<rect x="0.5" y="0.5" width="8" height="8" />
</svg>
)}
</button>
<button
onClick={handleClose}
Expand Down
164 changes: 158 additions & 6 deletions src/shared/stores/workflowsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,73 @@ interface WorkflowsStore {
workflows: Workflow[]
loading: boolean
activeId: string | null
/** Workflows shown as tabs. Closing a tab only removes it here — the file stays on disk. */
openIds: string[]
/** Folder names for the workflow browser (workflows reference them via their `folder` field) */
folders: string[]
/** Folder name → display color (tab stripes + folder icons) */
folderColors: Record<string, string>
/** Folder names pinned to the top of the workflow browser */
bookmarkedFolders: string[]

load: () => Promise<void>
save: (workflow: Workflow) => Promise<{ success: boolean; error?: string }>
remove: (id: string) => Promise<{ success: boolean; error?: string }>
importFile: () => Promise<{ success: boolean; error?: string; workflow?: Workflow }>
exportFile: (workflow: Workflow) => Promise<{ success: boolean; error?: string }>
setActive: (id: string | null) => void
/** Add a workflow to the tab bar (if needed) and make it active. */
openWorkflow: (id: string) => void
/** Remove a workflow from the tab bar without deleting it. */
closeWorkflow: (id: string) => void
/** Reorder tabs: move dragId to targetId's position. */
moveOpenTab: (dragId: string, targetId: string) => void
/** Create a browser folder (kept even while empty; persisted in localStorage). */
addFolder: (name: string) => void
/** Remove a browser folder name (workflows inside must be moved out first by the caller). */
removeFolder: (name: string) => void
/** Set a folder's display color. */
setFolderColor: (name: string, color: string) => void
/** Pin/unpin a folder at the top of the workflow browser. */
toggleFolderBookmark: (name: string) => void
}

/** Palette for folder colors (tab stripes / folder icons). */
export const FOLDER_COLORS = ['#38bdf8', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#f472b6', '#94a3b8']

// Empty folders have no workflow referencing them, so their names (and colors)
// are persisted separately in localStorage to survive reloads.
const FOLDERS_KEY = 'modly-workflow-folders'

function readStoredFolders(): { names: string[]; colors: Record<string, string>; bookmarked: string[] } {
try {
const raw = JSON.parse(localStorage.getItem(FOLDERS_KEY) ?? '[]')
// Legacy format: plain array of names
if (Array.isArray(raw)) {
return { names: raw.filter((f): f is string => typeof f === 'string'), colors: {}, bookmarked: [] }
}
if (raw && typeof raw === 'object') {
const names = Array.isArray(raw.names) ? raw.names.filter((f: unknown): f is string => typeof f === 'string') : []
const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors as Record<string, string> : {}
const bookmarked = Array.isArray(raw.bookmarked) ? raw.bookmarked.filter((f: unknown): f is string => typeof f === 'string') : []
return { names, colors, bookmarked }
}
return { names: [], colors: {}, bookmarked: [] }
} catch {
return { names: [], colors: {}, bookmarked: [] }
}
}

function storeFolders(names: string[], colors: Record<string, string>, bookmarked: string[]): void {
try { localStorage.setItem(FOLDERS_KEY, JSON.stringify({ names, colors, bookmarked })) } catch { /* quota/private mode */ }
}

/** Least-used palette color, so new folders spread across the palette. */
function nextFolderColor(colors: Record<string, string>): string {
const used = Object.values(colors)
return [...FOLDER_COLORS].sort((a, b) =>
used.filter((c) => c === a).length - used.filter((c) => c === b).length,
)[0]
}

// ─── Legacy migration ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -112,6 +172,10 @@ export const useWorkflowsStore = create<WorkflowsStore>((set) => ({
workflows: [],
loading: false,
activeId: null,
openIds: [],
folders: readStoredFolders().names,
folderColors: readStoredFolders().colors,
bookmarkedFolders: readStoredFolders().bookmarked,

async load() {
set({ loading: true })
Expand All @@ -128,7 +192,18 @@ export const useWorkflowsStore = create<WorkflowsStore>((set) => ({
seen.add(wf.id)
list.push(wf)
}
set({ workflows: list, loading: false })
set((s) => {
// Keep already-open tabs that still exist; on a fresh session open the
// most recent workflow (the list arrives sorted by updatedAt desc).
const openIds = s.openIds.length > 0
? s.openIds.filter((id) => list.some((w) => w.id === id))
: (list.length > 0 ? [list[0].id] : [])
const activeId = s.activeId && openIds.includes(s.activeId) ? s.activeId : (openIds[0] ?? null)
// Folders referenced by workflows always exist, even if localStorage was cleared
const folders = [...new Set([...s.folders, ...list.map((w) => w.folder).filter((f): f is string => !!f)])]
storeFolders(folders, s.folderColors, s.bookmarkedFolders)
return { workflows: list, loading: false, openIds, activeId, folders }
})
} catch {
set({ loading: false })
}
Expand All @@ -151,10 +226,14 @@ export const useWorkflowsStore = create<WorkflowsStore>((set) => ({
async remove(id) {
const result = await window.electron.workflows.delete(id)
if (result.success) {
set((s) => ({
workflows: s.workflows.filter((w) => w.id !== id),
activeId: s.activeId === id ? null : s.activeId,
}))
set((s) => {
const openIds = s.openIds.filter((x) => x !== id)
return {
workflows: s.workflows.filter((w) => w.id !== id),
openIds,
activeId: s.activeId === id ? (openIds[0] ?? null) : s.activeId,
}
})
}
return result
},
Expand All @@ -165,7 +244,11 @@ export const useWorkflowsStore = create<WorkflowsStore>((set) => ({
const wf = migrateWorkflow(result.workflow as LegacyWorkflow)
set((s) => {
const filtered = s.workflows.filter((w) => w.id !== wf.id)
return { workflows: [wf, ...filtered], activeId: wf.id }
return {
workflows: [wf, ...filtered],
openIds: s.openIds.includes(wf.id) ? s.openIds : [...s.openIds, wf.id],
activeId: wf.id,
}
})
}
return result
Expand All @@ -178,4 +261,73 @@ export const useWorkflowsStore = create<WorkflowsStore>((set) => ({
setActive(id) {
set({ activeId: id })
},

openWorkflow(id) {
set((s) => ({
openIds: s.openIds.includes(id) ? s.openIds : [...s.openIds, id],
activeId: id,
}))
},

closeWorkflow(id) {
set((s) => {
const idx = s.openIds.indexOf(id)
const openIds = s.openIds.filter((x) => x !== id)
// When closing the active tab, activate the nearest remaining neighbour
const activeId = s.activeId === id
? (openIds[Math.min(Math.max(idx, 0), openIds.length - 1)] ?? null)
: s.activeId
return { openIds, activeId }
})
},

moveOpenTab(dragId, targetId) {
set((s) => {
if (dragId === targetId || !s.openIds.includes(dragId)) return s
const ids = s.openIds.filter((x) => x !== dragId)
const idx = ids.indexOf(targetId)
if (idx === -1) return s
ids.splice(idx, 0, dragId)
return { openIds: ids }
})
},

addFolder(name) {
set((s) => {
if (s.folders.includes(name)) return s
const folders = [...s.folders, name]
const folderColors = { ...s.folderColors, [name]: nextFolderColor(s.folderColors) }
storeFolders(folders, folderColors, s.bookmarkedFolders)
return { folders, folderColors }
})
},

removeFolder(name) {
set((s) => {
const folders = s.folders.filter((f) => f !== name)
const folderColors = { ...s.folderColors }
delete folderColors[name]
const bookmarkedFolders = s.bookmarkedFolders.filter((f) => f !== name)
storeFolders(folders, folderColors, bookmarkedFolders)
return { folders, folderColors, bookmarkedFolders }
})
},

setFolderColor(name, color) {
set((s) => {
const folderColors = { ...s.folderColors, [name]: color }
storeFolders(s.folders, folderColors, s.bookmarkedFolders)
return { folderColors }
})
},

toggleFolderBookmark(name) {
set((s) => {
const bookmarkedFolders = s.bookmarkedFolders.includes(name)
? s.bookmarkedFolders.filter((f) => f !== name)
: [...s.bookmarkedFolders, name]
storeFolders(s.folders, s.folderColors, bookmarkedFolders)
return { bookmarkedFolders }
})
},
}))
13 changes: 10 additions & 3 deletions src/shared/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export interface Workflow {
id: string
name: string
description: string
/** Display folder in the workflow browser (no folder = root) */
folder?: string
/** Pinned in the workflow browser's Bookmarks section */
bookmarked?: boolean
nodes: WFNode[]
edges: WFEdge[]
createdAt: string
Expand All @@ -134,9 +138,12 @@ declare global {
memory: () => Promise<{ total: number; used: number; available: number }>
}
window: {
minimize: () => void
maximize: () => void
close: () => void
minimize: () => void
maximize: () => void
close: () => void
isMaximized: () => Promise<boolean>
onMaximizeChange: (cb: (isMaximized: boolean) => void) => void
offMaximizeChange: () => void
}
ui: {
setZoomFactor: (factor: number) => void
Expand Down
Loading