diff --git a/.prettierignore b/.prettierignore index d24f30356..885ce6337 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ bin frontend/dist frontend/node_modules *.min.* +frontend/app/store/services.ts +frontend/types/gotypes.d.ts diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 34d777b33..0dc48463f 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -19,6 +19,7 @@ import ( "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/wavebase" + "github.com/wavetermdev/thenextwave/pkg/wconfig" "github.com/wavetermdev/thenextwave/pkg/web" "github.com/wavetermdev/thenextwave/pkg/wstore" ) @@ -34,6 +35,10 @@ func doShutdown(reason string) { defer cancelFn() // TODO deal with flush in progress filestore.WFS.FlushCache(ctx) + watcher := wconfig.GetWatcher() + if watcher != nil { + watcher.Close() + } time.Sleep(200 * time.Millisecond) os.Exit(0) }) @@ -62,6 +67,13 @@ func stdinReadWatch() { } } +func configWatcher() { + watcher := wconfig.GetWatcher() + if watcher != nil { + watcher.Start() + } +} + func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) log.SetPrefix("[wavesrv] ") @@ -106,6 +118,7 @@ func main() { } installShutdownSignalHandlers() go stdinReadWatch() + configWatcher() go web.RunWebSocketServer() go func() { diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index c3216cd83..3b6a9583b 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -56,6 +56,7 @@ const workspaceAtom: jotai.Atom = jotai.atom((get) => { } return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); }); +const settingsConfigAtom = jotai.atom(null) as jotai.PrimitiveAtom; const tabAtom: jotai.Atom = jotai.atom((get) => { const windowData = get(windowDataAtom); if (windowData == null) { @@ -72,6 +73,7 @@ const atoms = { client: clientAtom, waveWindow: windowDataAtom, workspace: workspaceAtom, + settingsConfigAtom: settingsConfigAtom, tabAtom: tabAtom, }; @@ -173,6 +175,13 @@ function handleWSEventMessage(msg: WSEventType) { console.log("unsupported event", msg); return; } + if (msg.eventtype == "config") { + const data: WatcherUpdate = msg.data; + globalStore.set(settingsConfigAtom, data.update); + + console.log("config", data); + return; + } if (msg.eventtype == "blockfile") { const fileData: WSFileEventData = msg.data; const fileSubject = getFileSubject(fileData.zoneid, fileData.filename); diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 91ab53c52..c54b73d98 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -45,12 +45,21 @@ export const ClientService = new ClientServiceType() // fileservice.FileService (file) class FileServiceType { + AddWidget(arg1: WidgetsConfigType): Promise { + return WOS.callBackendService("file", "AddWidget", Array.from(arguments)) + } + GetSettingsConfig(): Promise { + return WOS.callBackendService("file", "GetSettingsConfig", Array.from(arguments)) + } GetWaveFile(arg1: string, arg2: string): Promise { return WOS.callBackendService("file", "GetWaveFile", Array.from(arguments)) } ReadFile(arg1: string): Promise { return WOS.callBackendService("file", "ReadFile", Array.from(arguments)) } + RemoveWidget(arg1: number): Promise { + return WOS.callBackendService("file", "RemoveWidget", Array.from(arguments)) + } StatFile(arg1: string): Promise { return WOS.callBackendService("file", "StatFile", Array.from(arguments)) } diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index ddf6bf25c..0d0d34a2e 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -4,12 +4,16 @@ import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; import { atoms, createBlock } from "@/store/global"; +import * as services from "@/store/services"; import * as jotai from "jotai"; +import * as React from "react"; import { CenteredDiv } from "../element/quickelems"; import "./workspace.less"; function Widgets() { + const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom); + const newWidgetModalVisible = React.useState(false); async function clickTerminal() { const termBlockDef = { controller: "shell", @@ -18,51 +22,37 @@ function Widgets() { createBlock(termBlockDef); } - async function clickPreview(fileName: string) { - const markdownDef = { - view: "preview", - meta: { file: fileName }, - }; - createBlock(markdownDef); - } - - async function clickPlot() { - const plotDef: BlockDef = { - view: "plot", - }; - createBlock(plotDef); - } - async function clickEdit() { const editDef: BlockDef = { view: "codeedit", }; createBlock(editDef); } + async function handleWidgetSelect(blockDef: BlockDef) { + createBlock(blockDef); + } + + async function handleCreateWidget(newWidget: WidgetsConfigType) { + await services.FileService.AddWidget(newWidget); + } + + async function handleRemoveWidget(idx: number) { + await services.FileService.RmWidget(idx); + } return (
clickTerminal()}>
-
clickPreview("~/work/wails/thenextwave/README.md")}> - -
-
clickPreview("~/work/wails/thenextwave/go.mod")}> - -
-
clickPreview("~/work/wails/thenextwave/build/appicon.png")}> - -
-
clickPreview("~")}> - -
-
clickPlot()}> - -
clickEdit()}>
+ {settingsConfig.widgets.map((data, idx) => ( +
handleWidgetSelect(data.blockdef)} key={`widget-${idx}`}> + +
+ ))}
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d5d6f4653..d862ed946 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -282,6 +282,12 @@ declare global { updates?: WaveObjUpdate[]; }; + // wconfig.WidgetsConfigType + type WidgetsConfigType = { + icon: string; + blockdef: BlockDef; + }; + // wstore.WinSize type WinSize = { width: number; diff --git a/frontend/wave.ts b/frontend/wave.ts index 94758e2ed..ff0bb09e5 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -1,7 +1,7 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { globalStore, globalWS, initWS } from "@/store/global"; +import { atoms, globalStore, globalWS, initWS } from "@/store/global"; import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import * as React from "react"; @@ -37,6 +37,7 @@ document.addEventListener("DOMContentLoaded", async () => { const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", clientId)); const waveWindow = await WOS.loadAndPinWaveObject(WOS.makeORef("window", windowId)); await WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); + globalStore.set(atoms.settingsConfigAtom, await services.FileService.GetSettingsConfig()); services.ObjectService.SetActiveTab(waveWindow.activetabid); // no need to wait const reactElem = React.createElement(App, null, null); const elem = document.getElementById("main"); diff --git a/go.mod b/go.mod index 0f9859073..30369e5d1 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.22.1 require ( github.com/alexflint/go-filemutex v1.3.0 github.com/creack/pty v1.1.18 + github.com/fsnotify/fsnotify v1.7.0 github.com/golang-migrate/migrate/v4 v4.17.1 github.com/google/uuid v1.4.0 github.com/gorilla/handlers v1.5.2 diff --git a/go.sum b/go.sum index f496a4893..187bd8cef 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go index f610a5cb1..ca3524874 100644 --- a/pkg/service/fileservice/fileservice.go +++ b/pkg/service/fileservice/fileservice.go @@ -15,6 +15,7 @@ import ( "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/util/utilfn" "github.com/wavetermdev/thenextwave/pkg/wavebase" + "github.com/wavetermdev/thenextwave/pkg/wconfig" ) const MaxFileSize = 10 * 1024 * 1024 // 10M @@ -117,3 +118,18 @@ func (fs *FileService) GetWaveFile(id string, path string) (any, error) { } return file, nil } + +func (fs *FileService) GetSettingsConfig() interface{} { + watcher := wconfig.GetWatcher() + return watcher.GetSettingsConfig() +} + +func (fs *FileService) AddWidget(newWidget wconfig.WidgetsConfigType) error { + watcher := wconfig.GetWatcher() + return watcher.AddWidget(newWidget) +} + +func (fs *FileService) RemoveWidget(idx uint) error { + watcher := wconfig.GetWatcher() + return watcher.RmWidget(idx) +} diff --git a/pkg/util/utilfn/mimetypes.go b/pkg/util/utilfn/mimetypes.go index 5e36e38d2..cc012aa64 100644 --- a/pkg/util/utilfn/mimetypes.go +++ b/pkg/util/utilfn/mimetypes.go @@ -1180,8 +1180,12 @@ var StaticMimeTypeMap = map[string]string{ ".gl": "video/gl", ".m4s": "video/iso.segment", ".mj2": "video/mj2", + ".m4v": "video/mp4", + ".mkv": "video/mp4", + ".mov": "video/mp4", ".mp4": "video/mp4", ".mpeg": "video/mpeg", + ".mpg": "video/mpeg", ".ogv": "video/ogg", ".qt": "video/quicktime", ".uvh": "video/vnd.dece.hd", diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go new file mode 100644 index 000000000..ed96a7cd9 --- /dev/null +++ b/pkg/wconfig/filewatcher.go @@ -0,0 +1,244 @@ +package wconfig + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + + "github.com/fsnotify/fsnotify" + "github.com/wavetermdev/thenextwave/pkg/eventbus" + "github.com/wavetermdev/thenextwave/pkg/wavebase" +) + +const configDir = "config" + +var configDirAbsPath = filepath.Join(wavebase.GetWaveHomeDir(), configDir) + +var instance *Watcher +var once sync.Once + +type Watcher struct { + initialized bool + watcher *fsnotify.Watcher + mutex sync.Mutex + settingsFile string + getSettingsDefaults func() SettingsConfigType + settingsData SettingsConfigType +} + +type WatcherUpdate struct { + File string `json:"file"` + Update SettingsConfigType `json:"update"` + Error string `json:"error"` +} + +func readFileContents(filePath string, getDefaults func() SettingsConfigType) (*SettingsConfigType, error) { + if getDefaults == nil { + log.Printf("oopsie") + return nil, fmt.Errorf("watcher started without defaults") + } + content := getDefaults() + data, err := os.ReadFile(filePath) + if err != nil { + log.Printf("doopsie: %v", err) + return nil, err + } + if err := json.Unmarshal(data, &content); err != nil { + return nil, err + } + return &content, nil +} + +// GetWatcher returns the singleton instance of the Watcher +func GetWatcher() *Watcher { + once.Do(func() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("failed to create file watcher: %v", err) + return + } + os.MkdirAll(configDirAbsPath, 0751) + instance = &Watcher{watcher: watcher} + log.Printf("started config watcher: %v\n", configDirAbsPath) + if err := instance.addSettingsFile(settingsAbsPath, getSettingsConfigDefaults); err != nil { + log.Printf("failed to add path %s to watcher: %v", configDirAbsPath, err) + return + } + }) + return instance +} + +func (w *Watcher) addSettingsFile(filename string, getDefaults func() SettingsConfigType) error { + w.mutex.Lock() + defer w.mutex.Unlock() + + w.getSettingsDefaults = getDefaults + filename = filepath.ToSlash(filename) + + stat, err := os.Lstat(filename) + if err != nil { + fmt.Printf("warning: cannot stat file: %v", err) + } else { + if stat.IsDir() { + return fmt.Errorf("warning: can't watch directory instead of file: %v", err) + } + + } + w.settingsFile = filename + w.watcher.Add(filepath.Dir(filename)) + + return nil +} + +func (w *Watcher) Start() { + w.mutex.Lock() + defer w.mutex.Unlock() + + log.Printf("starting file watcher\n") + w.initialized = true + w.sendInitialValues() + + go func() { + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + w.handleEvent(event) + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + log.Println("watcher error:", err) + } + } + }() +} + +// for initial values, exit on first error +func (w *Watcher) sendInitialValues() error { + filename := w.settingsFile + + content, err := readFileContents(w.settingsFile, w.getSettingsDefaults) + if os.IsNotExist(err) || os.IsPermission(err) { + log.Printf("settings file cannot be read: using defaults") + defaults := w.getSettingsDefaults() + content = &defaults + } else if err != nil { + return err + } + + message := WatcherUpdate{ + File: filename, + Update: *content, + } + + w.settingsData = message.Update + + log.Printf("watcher: initial values: %s -> %v", filename, content) + return nil +} + +func (w *Watcher) Close() { + w.mutex.Lock() + defer w.mutex.Unlock() + if w.watcher != nil { + w.watcher.Close() + w.watcher = nil + log.Println("file watcher closed") + } +} + +func (w *Watcher) broadcast(message WatcherUpdate) { + // send to frontend + eventbus.SendEvent(eventbus.WSEventType{ + EventType: "config", + Data: message, + }) + + if message.File == w.settingsFile { + w.settingsData = message.Update + } + + if message.Error != "" { + log.Printf("watcher: error processing %s. sending defaults: %v", message.File, message.Error) + } else { + log.Printf("watcher: update: %s -> %v", message.File, message.Update) + } +} + +func (w *Watcher) GetSettingsConfig() SettingsConfigType { + w.mutex.Lock() + defer w.mutex.Unlock() + + return w.settingsData +} + +func (w *Watcher) handleEvent(event fsnotify.Event) { + w.mutex.Lock() + defer w.mutex.Unlock() + + fileName := filepath.ToSlash(event.Name) + + // only consider events for the tracked files + if fileName != w.settingsFile { + return + } + defaults := w.getSettingsDefaults() + + if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename { + message := WatcherUpdate{ + File: fileName, + Update: defaults, + } + w.broadcast(message) + } + + if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { + content, err := readFileContents(fileName, w.getSettingsDefaults) + if err != nil { + message := WatcherUpdate{ + File: fileName, + Update: defaults, + Error: err.Error(), + } + w.broadcast(message) + return + } + message := WatcherUpdate{ + File: fileName, + Update: *content, + } + + w.broadcast(message) + } +} + +func (w *Watcher) AddWidget(newWidget WidgetsConfigType) error { + current := w.GetSettingsConfig() + current.Widgets = append(current.Widgets, newWidget) + update, err := json.Marshal(current) + if err != nil { + return err + } + + os.MkdirAll(filepath.Dir(w.settingsFile), 0751) + return os.WriteFile(w.settingsFile, update, 0644) +} + +func (w *Watcher) RmWidget(idx uint) error { + current := w.GetSettingsConfig().Widgets + truncated := append(current[:idx], current[idx+1:]...) + update, err := json.Marshal(truncated) + if err != nil { + return err + } + + os.MkdirAll(filepath.Dir(w.settingsFile), 0751) + return os.WriteFile(w.settingsFile, update, 0644) +} diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go new file mode 100644 index 000000000..895ff9bc5 --- /dev/null +++ b/pkg/wconfig/settingsconfig.go @@ -0,0 +1,41 @@ +package wconfig + +import ( + "path/filepath" + + "github.com/wavetermdev/thenextwave/pkg/wavebase" + "github.com/wavetermdev/thenextwave/pkg/wstore" +) + +const settingsFile = "settings.json" + +var settingsAbsPath = filepath.Join(configDirAbsPath, settingsFile) + +type WidgetsConfigType struct { + Icon string `json:"icon"` + BlockDef wstore.BlockDef `json:"blockdef"` +} + +type SettingsConfigType struct { + Widgets []WidgetsConfigType `json:"widgets"` +} + +func getSettingsConfigDefaults() SettingsConfigType { + return SettingsConfigType{ + Widgets: []WidgetsConfigType{ + { + Icon: "fa fa-solid fa-files fa-fw", + BlockDef: wstore.BlockDef{ + View: "preview", + Meta: map[string]any{"file": wavebase.GetHomeDir()}, + }, + }, + { + Icon: "fa fa-solid fa-chart-simple fa-fw", + BlockDef: wstore.BlockDef{ + View: "plot", + }, + }, + }, + } +}