terminal themes feature (#135)

Co-authored-by: sawka
This commit is contained in:
Red J Adaya
2024-07-24 20:34:22 -07:00
committed by GitHub
co-authored by sawka
parent 2f3275e7a6
commit dcb4d5f2bf
12 changed files with 374 additions and 213 deletions
+142 -85
View File
@@ -5,10 +5,12 @@ package wconfig
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"github.com/fsnotify/fsnotify"
@@ -19,42 +21,99 @@ import (
const configDir = "config"
var configDirAbsPath = filepath.Join(wavebase.GetWaveHomeDir(), configDir)
var termThemesDirAbsPath = filepath.Join(configDirAbsPath, termThemesDir)
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
initialized bool
watcher *fsnotify.Watcher
mutex sync.Mutex
settingsData SettingsConfigType
}
type WatcherUpdate struct {
File string `json:"file"`
Update SettingsConfigType `json:"update"`
Error string `json:"error"`
Settings SettingsConfigType `json:"settings"`
Error string `json:"error"`
}
func readFileContents(filePath string, getDefaults func() SettingsConfigType) (*SettingsConfigType, error) {
if getDefaults == nil {
log.Printf("should not happen")
return nil, fmt.Errorf("watcher started without defaults")
}
content := getDefaults()
data, err := os.ReadFile(filePath)
func LoadFullSettings() (*SettingsConfigType, error) {
// first load settings.json
// then load themes
// then apply defaults
settings, err := readFileContents[SettingsConfigType](settingsAbsPath, false)
if err != nil {
log.Printf("could not read settings file: %v", err)
return nil, err
}
themes, err := readThemes()
if err != nil {
return nil, err
}
if settings.TermThemes == nil {
settings.TermThemes = make(map[string]TermThemeType)
}
for k, v := range themes {
settings.TermThemes[k] = v
}
applyDefaultSettings(settings)
return settings, nil
}
func readThemes() (map[string]TermThemeType, error) {
files, err := os.ReadDir(termThemesDirAbsPath)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("error reading themes directory: %v", err)
}
themes := make(map[string]TermThemeType)
for _, file := range files {
if !file.IsDir() && filepath.Ext(file.Name()) == ".json" {
log.Printf("reading theme file %s\n", file.Name())
theme, err := readFileContents[TermThemeType](filepath.Join(termThemesDirAbsPath, file.Name()), true)
if err != nil {
log.Printf("error reading theme file %s: %v", file.Name(), err)
continue
}
if theme == nil {
continue
}
themeName := getThemeName(file.Name())
themes[themeName] = *theme
}
}
return themes, nil
}
func readFileContents[T any](filePath string, nilOnNotExist bool) (*T, error) {
var content T
data, err := os.ReadFile(filePath)
if errors.Is(err, os.ErrNotExist) {
if nilOnNotExist {
return nil, nil
} else {
return &content, nil
}
}
if err != nil {
log.Printf("could not read file %s: %v", filePath, err)
return nil, err
}
if err := json.Unmarshal(data, &content); err != nil {
log.Printf("could not unmarshal file %s: %v", filePath, err)
return nil, err
}
return &content, nil
}
func isInDirectory(fileName, directory string) bool {
rel, err := filepath.Rel(directory, fileName)
return err == nil && !strings.HasPrefix(rel, "..")
}
// GetWatcher returns the singleton instance of the Watcher
func GetWatcher() *Watcher {
once.Do(func() {
@@ -63,36 +122,50 @@ func GetWatcher() *Watcher {
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)
if err := instance.addSettingsFile(settingsAbsPath); err != nil {
log.Printf("failed to add path %s to watcher: %v", settingsAbsPath, err)
return
}
if err := instance.addTermThemesDir(termThemesDirAbsPath); err != nil {
log.Printf("failed to add terminal themes path %s to watcher: %v", termThemesDirAbsPath, err)
return
}
})
return instance
}
func (w *Watcher) addSettingsFile(filename string, getDefaults func() SettingsConfigType) error {
func (w *Watcher) addSettingsFile(filePath string) error {
w.mutex.Lock()
defer w.mutex.Unlock()
w.getSettingsDefaults = getDefaults
filename = filepath.ToSlash(filename)
stat, err := os.Lstat(filename)
dir := filepath.Dir(filePath)
err := os.MkdirAll(dir, 0751)
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)
}
return fmt.Errorf("error creating config directory: %v", err)
}
w.settingsFile = filename
w.watcher.Add(filepath.Dir(filename))
w.watcher.Add(filePath)
log.Printf("started config watcher: %v\n", filePath)
return nil
}
func (w *Watcher) addTermThemesDir(dir string) error {
w.mutex.Lock()
defer w.mutex.Unlock()
_, err := os.Stat(dir)
if os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0751); err != nil {
return fmt.Errorf("error creating themes directory: %v", err)
}
} else if err != nil {
return fmt.Errorf("error accessing themes directory: %v", err)
}
if err := w.watcher.Add(dir); err != nil {
return fmt.Errorf("error adding themes directory to watcher: %v", err)
}
log.Printf("started termthemes watcher: %v\n", dir)
return nil
}
@@ -105,7 +178,6 @@ func (w *Watcher) Start() {
w.sendInitialValues()
go func() {
for {
select {
case event, ok := <-w.watcher.Events:
@@ -125,23 +197,15 @@ func (w *Watcher) Start() {
// 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 {
settings, err := LoadFullSettings()
if err != nil {
return err
}
w.settingsData = *settings
message := WatcherUpdate{
File: filename,
Update: *content,
Settings: w.settingsData,
}
w.settingsData = message.Update
w.broadcast(message)
return nil
}
@@ -162,14 +226,10 @@ func (w *Watcher) broadcast(message WatcherUpdate) {
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)
log.Printf("watcher: error processing update: %v. error: %s", message.Settings, message.Error)
} else {
log.Printf("watcher: update: %s -> %v", message.File, message.Update)
log.Printf("watcher: update: %v", message.Settings)
}
}
@@ -186,38 +246,35 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
fileName := filepath.ToSlash(event.Name)
// only consider events for the tracked files
if fileName != w.settingsFile {
if isInDirectory(fileName, termThemesDirAbsPath) {
w.handleTermThemesEvent(event, fileName)
} else if filepath.Base(fileName) == filepath.Base(settingsAbsPath) {
w.handleSettingsFileEvent(event, fileName)
}
}
func (w *Watcher) handleTermThemesEvent(event fsnotify.Event, fileName string) {
settings, err := LoadFullSettings()
if err != nil {
log.Printf("error loading settings after term-themes event: %v", err)
return
}
defaults := w.getSettingsDefaults()
w.settingsData = *settings
w.broadcast(WatcherUpdate{Settings: w.settingsData})
}
if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename {
message := WatcherUpdate{
File: fileName,
Update: defaults,
}
w.broadcast(message)
func (w *Watcher) handleSettingsFileEvent(event fsnotify.Event, fileName string) {
settings, err := LoadFullSettings()
if err != nil {
log.Printf("error loading settings after settings file event: %v", err)
return
}
w.settingsData = *settings
w.broadcast(WatcherUpdate{Settings: w.settingsData})
}
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 getThemeName(fileName string) string {
return strings.TrimSuffix(filepath.Base(fileName), filepath.Ext(fileName))
}
func (w *Watcher) AddWidget(newWidget WidgetsConfigType) error {
@@ -228,8 +285,8 @@ func (w *Watcher) AddWidget(newWidget WidgetsConfigType) error {
return err
}
os.MkdirAll(filepath.Dir(w.settingsFile), 0751)
return os.WriteFile(w.settingsFile, update, 0644)
os.MkdirAll(filepath.Dir(settingsFile), 0751)
return os.WriteFile(settingsFile, update, 0644)
}
func (w *Watcher) RmWidget(idx uint) error {
@@ -240,6 +297,6 @@ func (w *Watcher) RmWidget(idx uint) error {
return err
}
os.MkdirAll(filepath.Dir(w.settingsFile), 0751)
return os.WriteFile(w.settingsFile, update, 0644)
os.MkdirAll(filepath.Dir(settingsFile), 0751)
return os.WriteFile(settingsFile, update, 0644)
}
+130 -56
View File
@@ -10,6 +10,7 @@ import (
"github.com/wavetermdev/thenextwave/pkg/wstore"
)
const termThemesDir = "terminal-themes"
const settingsFile = "settings.json"
var settingsAbsPath = filepath.Join(configDirAbsPath, settingsFile)
@@ -41,71 +42,144 @@ type AutoUpdateOpts struct {
IntervalMs uint32 `json:"intervalms"`
}
type TermThemeType struct {
Black string `json:"black"`
Red string `json:"red"`
Green string `json:"green"`
Yellow string `json:"yellow"`
Blue string `json:"blue"`
Magenta string `json:"magenta"`
Cyan string `json:"cyan"`
White string `json:"white"`
BrightBlack string `json:"brightBlack"`
BrightRed string `json:"brightRed"`
BrightGreen string `json:"brightGreen"`
BrightYellow string `json:"brightYellow"`
BrightBlue string `json:"brightBlue"`
BrightMagenta string `json:"brightMagenta"`
BrightCyan string `json:"brightCyan"`
BrightWhite string `json:"brightWhite"`
Gray string `json:"gray"`
CmdText string `json:"cmdtext"`
Foreground string `json:"foreground"`
SelectionBackground string `json:"selectionBackground"`
Background string `json:"background"`
CursorAccent string `json:"cursorAccent"`
}
type TermThemesConfigType map[string]TermThemeType
// TODO add default term theme settings
type SettingsConfigType struct {
MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"`
Term TerminalConfigType `json:"term"`
Widgets []WidgetsConfigType `json:"widgets"`
BlockHeader BlockHeaderOpts `json:"blockheader"`
AutoUpdate AutoUpdateOpts `json:"autoupdate"`
AutoUpdate *AutoUpdateOpts `json:"autoupdate"`
TermThemes TermThemesConfigType `json:"termthemes"`
}
func getSettingsConfigDefaults() SettingsConfigType {
return SettingsConfigType{
MimeTypes: map[string]MimeTypeConfigType{
"audio": {Icon: "file-audio"},
"application/pdf": {Icon: "file-pdf"},
"application/json": {Icon: "file-lines"},
"directory": {Icon: "folder", Color: "var(--term-bright-blue)"},
"font": {Icon: "book-font"},
"image": {Icon: "file-image"},
"text": {Icon: "file-lines"},
"text/css": {Icon: "css3-alt fa-brands"},
"text/javascript": {Icon: "js fa-brands"},
"text/typescript": {Icon: "js fa-brands"},
"text/golang": {Icon: "golang fa-brands"},
"text/html": {Icon: "html5 fa-brands"},
"text/less": {Icon: "less fa-brands"},
"text/markdown": {Icon: "markdown fa-brands"},
"text/rust": {Icon: "rust fa-brands"},
"text/scss": {Icon: "sass fa-brands"},
"video": {Icon: "file-video"},
"text/csv": {Icon: "file-csv"},
},
Widgets: []WidgetsConfigType{
{
Icon: "files",
Label: "files",
BlockDef: wstore.BlockDef{
View: "preview",
Meta: map[string]any{"file": wavebase.GetHomeDir()},
},
},
{
Icon: "chart-simple",
Label: "chart",
BlockDef: wstore.BlockDef{
View: "plot",
},
},
{
Icon: "globe",
Label: "web",
BlockDef: wstore.BlockDef{
View: "web",
Meta: map[string]any{"url": "https://waveterm.dev/"},
},
},
{
Icon: "sparkles",
Label: "waveai",
BlockDef: wstore.BlockDef{
View: "waveai",
},
},
},
AutoUpdate: AutoUpdateOpts{
var DefaultTermDarkTheme = TermThemeType{
Black: "#757575",
Red: "#cc685c",
Green: "#76c266",
Yellow: "#cbca9b",
Blue: "#85aacb",
Magenta: "#cc72ca",
Cyan: "#74a7cb",
White: "#c1c1c1",
BrightBlack: "#727272",
BrightRed: "#cc9d97",
BrightGreen: "#a3dd97",
BrightYellow: "#cbcaaa",
BrightBlue: "#9ab6cb",
BrightMagenta: "#cc8ecb",
BrightCyan: "#b7b8cb",
BrightWhite: "#f0f0f0",
Gray: "#8b918a",
CmdText: "#f0f0f0",
Foreground: "#c1c1c1",
SelectionBackground: "",
Background: "#00000000",
CursorAccent: "",
}
func applyDefaultSettings(settings *SettingsConfigType) {
defaultMimeTypes := map[string]MimeTypeConfigType{
"audio": {Icon: "file-audio"},
"application/pdf": {Icon: "file-pdf"},
"application/json": {Icon: "file-lines"},
"directory": {Icon: "folder", Color: "var(--term-bright-blue)"},
"font": {Icon: "book-font"},
"image": {Icon: "file-image"},
"text": {Icon: "file-lines"},
"text/css": {Icon: "css3-alt fa-brands"},
"text/javascript": {Icon: "js fa-brands"},
"text/typescript": {Icon: "js fa-brands"},
"text/golang": {Icon: "golang fa-brands"},
"text/html": {Icon: "html5 fa-brands"},
"text/less": {Icon: "less fa-brands"},
"text/markdown": {Icon: "markdown fa-brands"},
"text/rust": {Icon: "rust fa-brands"},
"text/scss": {Icon: "sass fa-brands"},
"video": {Icon: "file-video"},
"text/csv": {Icon: "file-csv"},
}
if settings.MimeTypes == nil {
settings.MimeTypes = defaultMimeTypes
} else {
for k, v := range defaultMimeTypes {
if _, found := settings.MimeTypes[k]; !found {
settings.MimeTypes[k] = v
}
}
}
if settings.AutoUpdate == nil {
settings.AutoUpdate = &AutoUpdateOpts{
Enabled: true,
IntervalMs: 3600000,
}
}
defaultWidgets := []WidgetsConfigType{
{
Icon: "files",
Label: "files",
BlockDef: wstore.BlockDef{
View: "preview",
Meta: map[string]any{"file": wavebase.GetHomeDir()},
},
},
{
Icon: "chart-simple",
Label: "chart",
BlockDef: wstore.BlockDef{
View: "plot",
},
},
{
Icon: "globe",
Label: "web",
BlockDef: wstore.BlockDef{
View: "web",
Meta: map[string]any{"url": "https://waveterm.dev/"},
},
},
{
Icon: "sparkles",
Label: "waveai",
BlockDef: wstore.BlockDef{
View: "waveai",
},
},
}
if settings.Widgets == nil {
settings.Widgets = defaultWidgets
}
if settings.TermThemes == nil {
settings.TermThemes = make(map[string]TermThemeType)
}
if _, found := settings.TermThemes["default-dark"]; !found {
settings.TermThemes["default-dark"] = DefaultTermDarkTheme
}
}