diff --git a/app.go b/app.go index c74c84a..408d5b4 100644 --- a/app.go +++ b/app.go @@ -699,6 +699,20 @@ func (a *App) startup(ctx context.Context) { usingDefault = false } } + // A rename in a previous session left the OLD file to delete now that it's no + // longer open. Only ever delete a file that ISN'T the one we're about to use. + if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" { + old := strings.TrimSpace(boot.DeletePending) + if old != a.dbPath { + for _, p := range []string{old, old + "-wal", old + "-shm"} { + if err := os.Remove(p); err == nil { + fmt.Printf("OpsLog: removed old database file %s\n", p) + } + } + } + boot.DeletePending = "" + _ = writeBootstrap(dataDir, boot) + } if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil { a.startupErr = "cannot create db folder: " + err.Error() fmt.Println("OpsLog:", a.startupErr) @@ -1340,6 +1354,11 @@ func copyFileData(src, dst string) error { type dbPointer struct { DBPath string `json:"db_path"` MySQL *MySQLSettings `json:"mysql,omitempty"` + // DeletePending is the previous database file to remove on the NEXT launch — + // set by a rename, which can't delete the still-open old file in-process. The + // startup path deletes it (with its -wal/-shm sidecars) once the new DB is the + // one in use, then clears this. + DeletePending string `json:"delete_pending,omitempty"` } func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") } @@ -1653,9 +1672,10 @@ func (a *App) PickSaveDatabase() (string, error) { return "", fmt.Errorf("no app context") } return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ - Title: "Save the OpsLog database to…", - DefaultFilename: "opslog.db", - Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, + Title: "Save the OpsLog database to…", + DefaultDirectory: filepath.Dir(a.dbPath), + DefaultFilename: "opslog.db", + Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, }) } @@ -1697,6 +1717,44 @@ func (a *App) MoveDatabase(dest string) error { return writeDBPointer(a.dataDir, dest) } +// RenameDatabase renames the current database file to dest — keeping ALL config +// (it is the same database under a new name), unlike "New database" which starts +// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the +// ORIGINAL file is scheduled for deletion on the next launch (it is open now and +// can't be removed in-process on Windows). dest must not already exist. +func (a *App) RenameDatabase(dest string) error { + dest = strings.TrimSpace(dest) + if dest == "" { + return fmt.Errorf("no destination given") + } + if a.db == nil { + return fmt.Errorf("database not open") + } + old := a.dbPath + if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) { + return fmt.Errorf("that is already the current database name") + } + if _, err := os.Stat(dest); err == nil { + return fmt.Errorf("a file already exists at %s — pick a new name", dest) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create folder: %w", err) + } + safe := strings.ReplaceAll(dest, "'", "''") + if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil { + return fmt.Errorf("copy database: %w", err) + } + boot := readBootstrap(a.dataDir) + boot.DBPath = dest + // Only schedule the old file for deletion when it's a real, on-disk file (a + // custom path or the default opslog.db) — never something we somehow share + // with the destination. + if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) { + boot.DeletePending = old + } + return writeBootstrap(a.dataDir, boot) +} + // CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and // points OpsLog at it for the next launch. dest must not already exist. func (a *App) CreateDatabase(dest string) error { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index fb56074..03fc63e 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,7 +3,7 @@ import { ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, - Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, + Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, @@ -28,7 +28,7 @@ import { ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, - GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, + GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetTelemetryEnabled, SetTelemetryEnabled, @@ -3978,6 +3978,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setDbMsg(p); } catch (e: any) { setErr(String(e?.message ?? e)); } } + // Rename the CURRENT database (keeps all config), unlike New database which + // starts empty. The old file is removed on the next launch. + async function renameDb() { + try { + const p = await PickSaveDatabase(); + if (!p) return; + await RenameDatabase(p); + await refreshDb(); + setDbMsg(p); + } catch (e: any) { setErr(String(e?.message ?? e)); } + } async function resetDefault() { try { await ResetDatabaseToDefault(); @@ -4056,6 +4067,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan