Add filewatcher for config files (#63)

This adds the filewatcher and forwards events to the frontend. It also
sets up the widgets as something that can be controlled with a config
file.
This commit is contained in:
Sylvie Crowe 2024-06-19 23:59:41 -07:00 committed by GitHub
parent c7f76d5ced
commit 21fa9a601f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 369 additions and 31 deletions

View File

@ -4,3 +4,5 @@ bin
frontend/dist
frontend/node_modules
*.min.*
frontend/app/store/services.ts
frontend/types/gotypes.d.ts

View File

@ -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() {

View File

@ -56,6 +56,7 @@ const workspaceAtom: jotai.Atom<Workspace> = jotai.atom((get) => {
}
return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
});
const settingsConfigAtom = jotai.atom(null) as jotai.PrimitiveAtom<SettingsConfigType>;
const tabAtom: jotai.Atom<Tab> = 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);

View File

@ -45,12 +45,21 @@ export const ClientService = new ClientServiceType()
// fileservice.FileService (file)
class FileServiceType {
AddWidget(arg1: WidgetsConfigType): Promise<void> {
return WOS.callBackendService("file", "AddWidget", Array.from(arguments))
}
GetSettingsConfig(): Promise<any> {
return WOS.callBackendService("file", "GetSettingsConfig", Array.from(arguments))
}
GetWaveFile(arg1: string, arg2: string): Promise<any> {
return WOS.callBackendService("file", "GetWaveFile", Array.from(arguments))
}
ReadFile(arg1: string): Promise<FullFile> {
return WOS.callBackendService("file", "ReadFile", Array.from(arguments))
}
RemoveWidget(arg1: number): Promise<void> {
return WOS.callBackendService("file", "RemoveWidget", Array.from(arguments))
}
StatFile(arg1: string): Promise<FileInfo> {
return WOS.callBackendService("file", "StatFile", Array.from(arguments))
}

View File

@ -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 (
<div className="workspace-widgets">
<div className="widget" onClick={() => clickTerminal()}>
<i className="fa fa-solid fa-square-terminal fa-fw" />
</div>
<div className="widget" onClick={() => clickPreview("~/work/wails/thenextwave/README.md")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPreview("~/work/wails/thenextwave/go.mod")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPreview("~/work/wails/thenextwave/build/appicon.png")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPreview("~")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPlot()}>
<i className="fa fa-solid fa-chart-simple fa-fw" />
</div>
<div className="widget" onClick={() => clickEdit()}>
<i className="fa-sharp fa-solid fa-pen-to-square"></i>
</div>
{settingsConfig.widgets.map((data, idx) => (
<div className="widget" onClick={() => handleWidgetSelect(data.blockdef)} key={`widget-${idx}`}>
<i className={data.icon}></i>
</div>
))}
<div className="widget no-hover">
<i className="fa fa-solid fa-plus fa-fw" />
</div>

View File

@ -282,6 +282,12 @@ declare global {
updates?: WaveObjUpdate[];
};
// wconfig.WidgetsConfigType
type WidgetsConfigType = {
icon: string;
blockdef: BlockDef;
};
// wstore.WinSize
type WinSize = {
width: number;

View File

@ -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<Client>(WOS.makeORef("client", clientId));
const waveWindow = await WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", windowId));
await WOS.loadAndPinWaveObject<Workspace>(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");

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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)
}

View File

@ -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",

244
pkg/wconfig/filewatcher.go Normal file
View File

@ -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)
}

View File

@ -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",
},
},
},
}
}