From 42b817949cd3ab7de89860761ec9012edc69d478 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 13:09:20 -0700 Subject: [PATCH] notepad improvements --- src/components/App/StatusBar.tsx | 2 +- src/components/Notepad/CloseConfirmModal.tsx | 31 +++ src/components/Notepad/Container.tsx | 66 +++++-- src/components/Notepad/Editor.tsx | 30 ++- src/components/Notepad/Tabs.tsx | 190 +++++++++++++++++-- src/constants/notepad.ts | 2 - src/main/modules/koboldcpp/download.ts | 1 + src/preload/index.ts | 10 + src/stores/notepad.ts | 12 +- src/types/electron.d.ts | 9 +- src/types/ipc.d.ts | 4 +- 11 files changed, 308 insertions(+), 49 deletions(-) create mode 100644 src/components/Notepad/CloseConfirmModal.tsx diff --git a/src/components/App/StatusBar.tsx b/src/components/App/StatusBar.tsx index 920eb0e..6d23dbc 100644 --- a/src/components/App/StatusBar.tsx +++ b/src/components/App/StatusBar.tsx @@ -72,7 +72,7 @@ export const StatusBar = ({ maxDataPoints = 60 }: StatusBarProps) => { bg={colorScheme === 'dark' ? 'dark.6' : 'gray.1'} > - + void; + onCancel: () => void; +} + +export const CloseConfirmModal = ({ + isOpen, + tabTitle, + onConfirm, + onCancel, +}: CloseConfirmModalProps) => ( + + + The tab “{tabTitle}” contains content. Closing it will + permanently delete this data. Are you sure you want to close it? + + + + + + +); diff --git a/src/components/Notepad/Container.tsx b/src/components/Notepad/Container.tsx index 1543b24..03a8405 100644 --- a/src/components/Notepad/Container.tsx +++ b/src/components/Notepad/Container.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState, type MouseEvent } from 'react'; import { Box, Paper, ActionIcon } from '@mantine/core'; -import { X, Plus } from 'lucide-react'; +import { X } from 'lucide-react'; import { useNotepadStore } from '@/stores/notepad'; import { usePreferencesStore } from '@/stores/preferences'; import { NOTEPAD_MIN_WIDTH, NOTEPAD_MIN_HEIGHT } from '@/constants/notepad'; import { NotepadTabs } from './Tabs.tsx'; import { NotepadEditor } from './Editor.tsx'; +import { CloseConfirmModal } from './CloseConfirmModal.tsx'; export const NotepadContainer = () => { const { @@ -14,6 +15,7 @@ export const NotepadContainer = () => { tabs, activeTabId, addTab, + removeTab, isLoaded, isVisible, setVisible, @@ -22,6 +24,15 @@ export const NotepadContainer = () => { const containerRef = useRef(null); const [resizeDirection, setResizeDirection] = useState(null); + const [confirmCloseModal, setConfirmCloseModal] = useState<{ + isOpen: boolean; + tabId: string | null; + tabTitle: string; + }>({ + isOpen: false, + tabId: null, + tabTitle: '', + }); const activeTab = tabs.find((tab) => tab.id === activeTabId); @@ -30,6 +41,32 @@ export const NotepadContainer = () => { addTab(newTab); }; + const handleTabCloseRequest = (tabId: string) => { + const tab = tabs.find((t) => t.id === tabId); + if (!tab) return; + + if (tab.content.trim().length > 0) { + setConfirmCloseModal({ + isOpen: true, + tabId, + tabTitle: tab.title, + }); + } else { + removeTab(tabId); + } + }; + + const handleConfirmClose = () => { + if (confirmCloseModal.tabId) { + removeTab(confirmCloseModal.tabId); + } + setConfirmCloseModal({ isOpen: false, tabId: null, tabTitle: '' }); + }; + + const handleCancelClose = () => { + setConfirmCloseModal({ isOpen: false, tabId: null, tabTitle: '' }); + }; + const handleResizeStart = (direction: string) => (e: MouseEvent) => { e.stopPropagation(); @@ -87,11 +124,11 @@ export const NotepadContainer = () => { withBorder style={{ position: 'fixed', - left: 5, - bottom: 5, + left: 0, + bottom: 24, width: position.width, height: position.height, - zIndex: 1000, + zIndex: 100, backgroundColor: resolvedColorScheme === 'dark' ? 'var(--mantine-color-dark-6)' @@ -132,11 +169,6 @@ export const NotepadContainer = () => { width: 12, height: 12, cursor: 'ne-resize', - backgroundColor: - resolvedColorScheme === 'dark' - ? 'var(--mantine-color-dark-5)' - : 'var(--mantine-color-gray-4)', - borderRadius: '2px', }} onMouseDown={handleResizeStart('top-right')} /> @@ -155,7 +187,10 @@ export const NotepadContainer = () => { minHeight: 28, }} > - + { flexShrink: 0, }} > - - - - { {activeTab && } + + ); }; diff --git a/src/components/Notepad/Editor.tsx b/src/components/Notepad/Editor.tsx index 99cec93..3976db4 100644 --- a/src/components/Notepad/Editor.tsx +++ b/src/components/Notepad/Editor.tsx @@ -1,9 +1,16 @@ -import { useCallback, useEffect, useState, useRef } from 'react'; +import { + useCallback, + useEffect, + useState, + useRef, + type MouseEvent, +} from 'react'; import { Box } from '@mantine/core'; import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror'; import { search } from '@codemirror/search'; -import { EditorView } from '@codemirror/view'; +import { EditorView, highlightActiveLine, keymap } from '@codemirror/view'; import { oneDark } from '@codemirror/theme-one-dark'; +import { history, historyKeymap } from '@codemirror/commands'; import { useNotepadStore } from '@/stores/notepad'; import { usePreferencesStore } from '@/stores/preferences'; import type { NotepadTab } from '@/types/electron'; @@ -13,7 +20,8 @@ interface NotepadEditorProps { } export const NotepadEditor = ({ tab }: NotepadEditorProps) => { - const { saveTabContent } = useNotepadStore(); + const { saveTabContent, showLineNumbers, setShowLineNumbers } = + useNotepadStore(); const { resolvedColorScheme } = usePreferencesStore(); const [content, setContent] = useState(tab.content); const [saveTimeout, setSaveTimeout] = useState { [tab.id, saveTabContent, saveTimeout] ); + const handleEditorContextMenu = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + if (target.closest('.cm-gutters') || target.closest('.cm-lineNumbers')) { + e.preventDefault(); + setShowLineNumbers(false); + } + }; const handleBoxClick = useCallback(() => { if (editorRef.current?.view) { editorRef.current.view.focus(); @@ -59,6 +75,9 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => { const extensions = [ search(), + history(), + keymap.of(historyKeymap), + highlightActiveLine(), EditorView.lineWrapping, EditorView.theme({ '&': { @@ -72,12 +91,13 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => { return ( { theme={theme} extensions={extensions} basicSetup={{ - lineNumbers: true, + lineNumbers: showLineNumbers, foldGutter: false, dropCursor: false, allowMultipleSelections: false, diff --git a/src/components/Notepad/Tabs.tsx b/src/components/Notepad/Tabs.tsx index f615ee5..f5bb7bb 100644 --- a/src/components/Notepad/Tabs.tsx +++ b/src/components/Notepad/Tabs.tsx @@ -1,9 +1,21 @@ -import { type MouseEvent, type DragEvent, useState } from 'react'; -import { Box, ActionIcon, Text } from '@mantine/core'; -import { X } from 'lucide-react'; +import { + type MouseEvent, + type DragEvent, + type KeyboardEvent, + useState, + useRef, + useEffect, +} from 'react'; +import { Box, ActionIcon, Text, TextInput } from '@mantine/core'; +import { X, Plus } from 'lucide-react'; import { useNotepadStore } from '@/stores/notepad'; import { usePreferencesStore } from '@/stores/preferences'; +interface NotepadTabsProps { + onCreateNewTab: () => Promise; + onCloseTab: (tabId: string) => void; +} + interface TabProps { id: string; index: number; @@ -14,7 +26,10 @@ interface TabProps { onDragStart: (e: DragEvent, index: number) => void; onDragOver: (e: DragEvent) => void; onDrop: (e: DragEvent, index: number) => void; + onRename: (newTitle: string) => void; isDragOver: boolean; + showLineNumbers: boolean; + setShowLineNumbers: (show: boolean) => void; } const Tab = ({ @@ -26,14 +41,77 @@ const Tab = ({ onDragStart, onDragOver, onDrop, + onRename, isDragOver, + showLineNumbers, + setShowLineNumbers, }: TabProps) => { const { resolvedColorScheme } = usePreferencesStore(); + const [isEditing, setIsEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(title); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleTitleClick = (e: MouseEvent) => { + e.stopPropagation(); + if (!isActive) { + onSelect(); + } + }; + + const handleTitleDoubleClick = (e: MouseEvent) => { + e.stopPropagation(); + if (isActive) { + setIsEditing(true); + setEditingTitle(title); + } + }; + + const handleInputKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleSaveTitle(); + } else if (e.key === 'Escape') { + setIsEditing(false); + setEditingTitle(title); + } + }; + + const handleInputBlur = () => { + handleSaveTitle(); + }; + + const handleSaveTitle = () => { + const trimmedTitle = editingTitle.trim(); + if (trimmedTitle && trimmedTitle !== title) { + onRename(trimmedTitle); + } + setIsEditing(false); + }; + + const handleTabClick = () => { + if (!isEditing) { + onSelect(); + } + }; + + const handleMouseDown = (e: MouseEvent) => { + if (e.button === 1) { + e.preventDefault(); + onClose(e); + } + }; return ( onDragStart(e, index)} onDragOver={onDragOver} onDrop={(e) => onDrop(e, index)} @@ -53,7 +131,7 @@ const Tab = ({ ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-3)' }`, - cursor: 'pointer', + cursor: isEditing ? 'default' : 'pointer', display: 'flex', alignItems: 'center', gap: '4px', @@ -62,17 +140,49 @@ const Tab = ({ opacity: isDragOver ? 0.5 : 1, }} > - - {title} - + {isEditing ? ( + setEditingTitle(e.target.value)} + onKeyDown={handleInputKeyDown} + onBlur={handleInputBlur} + size="xs" + variant="unstyled" + style={{ + flex: 1, + minWidth: 0, + }} + styles={{ + input: { + fontSize: 'var(--mantine-font-size-xs)', + padding: 0, + minHeight: 'auto', + height: 'auto', + lineHeight: 1, + }, + }} + /> + ) : ( + { + e.preventDefault(); + e.stopPropagation(); + setShowLineNumbers(!showLineNumbers); + }} + style={{ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + flex: 1, + }} + > + {title} + + )} @@ -81,9 +191,19 @@ const Tab = ({ ); }; -export const NotepadTabs = () => { - const { tabs, activeTabId, setActiveTab, removeTab, reorderTabs } = - useNotepadStore(); +export const NotepadTabs = ({ + onCreateNewTab, + onCloseTab, +}: NotepadTabsProps) => { + const { + tabs, + activeTabId, + setActiveTab, + reorderTabs, + updateTab, + showLineNumbers, + setShowLineNumbers, + } = useNotepadStore(); const { resolvedColorScheme } = usePreferencesStore(); const [draggedTabIndex, setDraggedTabIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -94,7 +214,19 @@ export const NotepadTabs = () => { const handleTabClose = (e: MouseEvent, tabId: string) => { e.stopPropagation(); - removeTab(tabId); + onCloseTab(tabId); + }; + + const handleTabRename = (tabId: string, newTitle: string) => { + updateTab(tabId, { title: newTitle }); + }; + + const handleTabBarContextMenu = (e: MouseEvent) => { + e.preventDefault(); + + if (!showLineNumbers) { + setShowLineNumbers(true); + } }; const handleDragStart = (e: DragEvent, index: number) => { @@ -130,6 +262,7 @@ export const NotepadTabs = () => { return ( { title={tab.title} onSelect={() => handleTabSelect(tab.id)} onClose={(e) => handleTabClose(e, tab.id)} + onRename={(newTitle) => handleTabRename(tab.id, newTitle)} onDragStart={handleDragStart} onDragOver={handleDragOver} onDrop={handleDrop} isDragOver={dragOverIndex === index} + showLineNumbers={showLineNumbers} + setShowLineNumbers={setShowLineNumbers} /> ))} + + + + ); }; diff --git a/src/constants/notepad.ts b/src/constants/notepad.ts index aa65d91..cfa2b75 100644 --- a/src/constants/notepad.ts +++ b/src/constants/notepad.ts @@ -1,6 +1,4 @@ export const DEFAULT_NOTEPAD_POSITION = { - x: 0, - y: 0, width: 400, height: 400, }; diff --git a/src/main/modules/koboldcpp/download.ts b/src/main/modules/koboldcpp/download.ts index 08e0b52..10b7785 100644 --- a/src/main/modules/koboldcpp/download.ts +++ b/src/main/modules/koboldcpp/download.ts @@ -89,6 +89,7 @@ async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) { if (totalBytes > 0) { const progress = (downloadedBytes / totalBytes) * 100; const now = Date.now(); + if (now - lastProgressUpdate > 100) { mainWindow.webContents.send('download-progress', progress); lastProgressUpdate = now; diff --git a/src/preload/index.ts b/src/preload/index.ts index 50eff46..77be374 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -117,6 +117,16 @@ const appAPI: AppAPI = { ipcRenderer.removeListener('window-unmaximized', handler); }; }, + onLineNumbersChanged: (callback) => { + const handler = (_: IpcRendererEvent, showLineNumbers: boolean) => + callback(showLineNumbers); + + ipcRenderer.on('line-numbers-changed', handler); + + return () => { + ipcRenderer.removeListener('line-numbers-changed', handler); + }; + }, }; const configAPI: ConfigAPI = { diff --git a/src/stores/notepad.ts b/src/stores/notepad.ts index e22901a..28d3b53 100644 --- a/src/stores/notepad.ts +++ b/src/stores/notepad.ts @@ -13,6 +13,7 @@ interface NotepadStore extends NotepadState { reorderTabs: (fromIndex: number, toIndex: number) => void; setPosition: (position: NotepadState['position']) => void; setVisible: (visible: boolean) => void; + setShowLineNumbers: (showLineNumbers: boolean) => void; loadState: () => Promise; saveState: () => Promise; saveTabContent: (tabId: string, content: string) => Promise; @@ -24,6 +25,7 @@ export const useNotepadStore = create()( activeTabId: null, position: DEFAULT_NOTEPAD_POSITION, isVisible: false, + showLineNumbers: true, isLoaded: false, setTabs: (tabs) => set({ tabs }), @@ -85,6 +87,10 @@ export const useNotepadStore = create()( set({ isVisible: visible }); }, + setShowLineNumbers: (showLineNumbers) => { + set({ showLineNumbers }); + }, + loadState: async () => { try { const savedState = await window.electronAPI.notepad.loadState(); @@ -106,6 +112,7 @@ export const useNotepadStore = create()( activeTabId: savedState.activeTabId || tabsWithContent[0]?.id || null, position: savedState.position, isVisible: savedState.isVisible, + showLineNumbers: savedState.showLineNumbers ?? true, isLoaded: true, }); } catch { @@ -132,6 +139,7 @@ export const useNotepadStore = create()( activeTabId: state.activeTabId, position: state.position, isVisible: state.isVisible, + showLineNumbers: state.showLineNumbers, }); }, @@ -148,6 +156,7 @@ useNotepadStore.subscribe( activeTabId: state.activeTabId, position: state.position, isVisible: state.isVisible, + showLineNumbers: state.showLineNumbers, }), () => { if (useNotepadStore.getState().isLoaded) { @@ -159,7 +168,8 @@ useNotepadStore.subscribe( a.tabs === b.tabs && a.activeTabId === b.activeTabId && a.position === b.position && - a.isVisible === b.isVisible, + a.isVisible === b.isVisible && + a.showLineNumbers === b.showLineNumbers, } ); diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index bb07ad8..9c34990 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -174,6 +174,9 @@ export interface AppAPI { quitAndInstall: () => Promise; isUpdateDownloaded: () => Promise; onWindowStateToggle: (callback: () => void) => () => void; + onLineNumbersChanged: ( + callback: (showLineNumbers: boolean) => void + ) => () => void; } export interface ConfigAPI { @@ -221,24 +224,22 @@ export interface NotepadState { tabs: NotepadTab[]; activeTabId: string | null; position: { - x: number; - y: number; width: number; height: number; }; isVisible: boolean; + showLineNumbers: boolean; } export interface SavedNotepadState { tabs: SavedNotepadTab[]; activeTabId: string | null; position: { - x: number; - y: number; width: number; height: number; }; isVisible: boolean; + showLineNumbers?: boolean; } export interface NotepadAPI { diff --git a/src/types/ipc.d.ts b/src/types/ipc.d.ts index b679953..658bceb 100644 --- a/src/types/ipc.d.ts +++ b/src/types/ipc.d.ts @@ -4,7 +4,8 @@ export type IPCChannel = | 'versions-updated' | 'kobold-output' | 'window-maximized' - | 'window-unmaximized'; + | 'window-unmaximized' + | 'line-numbers-changed'; export interface IPCChannelPayloads { 'download-progress': [progress: number]; @@ -13,4 +14,5 @@ export interface IPCChannelPayloads { 'kobold-output': [message: string]; 'window-maximized': []; 'window-unmaximized': []; + 'line-numbers-changed': [showLineNumbers: boolean]; }