From 712d306944b1981602eb9edcf5c2e9acf99c6726 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 21 Nov 2024 18:42:52 -0800 Subject: [PATCH] envedit program to edit nul delimited environment files --- envedit/envedit-main.go | 387 ++++++++++++++++++++++++++++++++++++++++ envedit/style.css | 203 +++++++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 envedit/envedit-main.go create mode 100644 envedit/style.css diff --git a/envedit/envedit-main.go b/envedit/envedit-main.go new file mode 100644 index 0000000..ab365de --- /dev/null +++ b/envedit/envedit-main.go @@ -0,0 +1,387 @@ +package main + +import ( + "context" + _ "embed" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/envutil" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/waveapp" +) + +//go:embed style.css +var styleCSS []byte + +var envPath string + +var AppClient = waveapp.MakeClient(waveapp.AppOpts{ + CloseOnCtrlC: true, + GlobalStyles: styleCSS, +}) + +type EnvItemProps struct { + Key string `json:"key"` + Value string `json:"value"` + OnEdit func() `json:"onEdit"` + OnDelete func() `json:"onDelete"` + OnSave func(string, string) `json:"onSave"` // Add this + OnCancel func() `json:"onCancel"` // Add this + IsEditing bool `json:"isEditing"` + Highlight bool `json:"highlight"` +} + +var EnvItem = waveapp.DefineComponent[EnvItemProps](AppClient, "EnvItem", + func(ctx context.Context, props EnvItemProps) any { + if props.IsEditing { + return EditForm(EditFormProps{ + Key: props.Key, + Value: props.Value, + IsNew: false, + OnSave: props.OnSave, // Use the passed save handler + OnCancel: props.OnCancel, // Use the passed cancel handler + }) + } + + return vdom.H("div", map[string]any{ + "className": vdom.Classes( + "env-item", + vdom.If(props.Highlight, "highlight"), + ), + }, + vdom.H("div", map[string]any{ + "className": "env-item-key", + }, props.Key), + vdom.H("div", map[string]any{ + "className": "env-item-value", + }, props.Value), + vdom.H("div", map[string]any{ + "className": "env-item-actions", + }, + vdom.H("button", map[string]any{ + "className": "env-item-edit", + "onClick": props.OnEdit, + "title": "Edit", + }, + vdom.H("i", map[string]any{ + "className": "fa fa-pencil", + }), + ), + vdom.H("button", map[string]any{ + "className": "env-item-delete", + "onClick": props.OnDelete, + "title": "Delete", + }, + vdom.H("i", map[string]any{ + "className": "fa fa-times", + }), + ), + ), + ) + }, +) + +type EditFormProps struct { + Key string `json:"key"` + Value string `json:"value"` + IsNew bool `json:"isNew"` + OnSave func(string, string) `json:"onSave"` + OnCancel func() `json:"onCancel"` +} + +var EditForm = waveapp.DefineComponent[EditFormProps](AppClient, "EditForm", + func(ctx context.Context, props EditFormProps) any { + key, setKey := vdom.UseState(ctx, props.Key) + value, setValue := vdom.UseState(ctx, props.Value) + error, setError := vdom.UseState(ctx, "") + + handleSave := func() { + if props.IsNew && key == "" { + setError("Key cannot be empty") + return + } + if strings.ContainsAny(key, "=\x00") { + setError("Key cannot contain '=' or null character") + return + } + if strings.Contains(value, "\x00") { + setError("Value cannot contain null character") + return + } + props.OnSave(key, value) + } + + keyHandler := &vdom.VDomFunc{ + Type: vdom.ObjectType_Func, + Fn: func(e vdom.VDomEvent) { + switch e.KeyData.Key { + case "Enter": + if !e.KeyData.Shift { + handleSave() + } + case "Escape": + props.OnCancel() + } + }, + Keys: []string{"Enter", "Escape"}, + PreventDefault: true, + } + + return vdom.H("div", map[string]any{ + "className": "env-item env-item-editing", + }, + vdom.H("div", map[string]any{ + "className": "env-item-key", + }, + vdom.IfElse(props.IsNew, + vdom.H("input", map[string]any{ + "className": "env-edit-key", + "value": key, + "onChange": func(e vdom.VDomEvent) { setKey(e.TargetValue) }, + "placeholder": "Key", + "onKeyDown": keyHandler, + }), + vdom.H("div", map[string]any{ + "className": "env-key-static", + }, props.Key), + ), + ), + vdom.H("div", map[string]any{ + "className": "env-item-value", + }, + vdom.H("textarea", map[string]any{ + "className": "env-edit-value", + "value": value, + "onChange": func(e vdom.VDomEvent) { setValue(e.TargetValue) }, + "placeholder": "Value", + "onKeyDown": keyHandler, + "rows": 3, + }), + ), + vdom.If(error != "", + vdom.H("div", map[string]any{ + "className": "env-edit-error", + }, error), + ), + vdom.H("div", map[string]any{ + "className": "env-edit-actions", + }, + vdom.H("button", map[string]any{ + "className": "env-edit-cancel", + "onClick": props.OnCancel, + }, "Cancel"), + vdom.H("button", map[string]any{ + "className": "env-edit-save", + "onClick": handleSave, + }, + vdom.H("i", map[string]any{ + "className": "fa fa-check", + }), + " Save", + ), + ), + ) + }, +) + +// HeaderProps breaks out all the header functionality +type HeaderProps struct { + Path string `json:"path"` + OnAddNew func() `json:"onAddNew"` + IsEditing bool `json:"isEditing"` +} + +var Header = waveapp.DefineComponent[HeaderProps](AppClient, "Header", + func(ctx context.Context, props HeaderProps) any { + return vdom.H("div", map[string]any{ + "className": "env-header", + }, + vdom.H("h1", nil, "Environment Editor"), + vdom.H("div", map[string]any{ + "className": "env-path", + }, "Path: ", props.Path), + vdom.H("button", map[string]any{ + "className": vdom.Classes( + "env-add", + vdom.If(props.IsEditing, "env-add-active"), + ), + "onClick": props.OnAddNew, + "title": vdom.IfElse(props.IsEditing, "Close editor", "Add variable"), + }, + vdom.H("i", map[string]any{ + "className": vdom.Classes( + "fa", + vdom.IfElse(props.IsEditing, "fa-times", "fa-plus"), + ), + }), + vdom.IfElse(props.IsEditing, " Close", " Add Variable"), + ), + ) + }, +) + +var App = waveapp.DefineComponent(AppClient, "App", + func(ctx context.Context, _ any) any { + envMap, setEnvMap := vdom.UseState(ctx, map[string]string{}) + editingKey, setEditingKey := vdom.UseState(ctx, "") + error, setError := vdom.UseState(ctx, "") + highlightKey, setHighlightKey := vdom.UseState(ctx, "") + + // Clear highlight after delay + vdom.UseEffect(ctx, func() func() { + if highlightKey != "" { + timer := time.AfterFunc(1500*time.Millisecond, func() { + setHighlightKey("") + AppClient.SendAsyncInitiation() + }) + return func() { + timer.Stop() + } + } + return nil + }, []any{highlightKey}) + + // Load environment file on mount + vdom.UseEffect(ctx, func() func() { + content, err := os.ReadFile(envPath) + if err != nil && !os.IsNotExist(err) { + setError(fmt.Sprintf("Error reading file: %v", err)) + return nil + } + if len(content) > 0 { + setEnvMap(envutil.EnvToMap(string(content))) + } + return nil + }, []any{}) + + // Save environment to file + saveToFile := func(newMap map[string]string) { + content := envutil.MapToEnv(newMap) + err := os.WriteFile(envPath, []byte(content), 0644) + if err != nil { + setError(fmt.Sprintf("Error saving file: %v", err)) + return + } + setEnvMap(newMap) + } + + handleAdd := func() { + if editingKey != "" { + setEditingKey("") + } else { + setEditingKey("__new__") + } + } + + handleEdit := func(key string) { + if editingKey == key { + setEditingKey("") + } else { + setEditingKey(key) + } + } + + handleDelete := func(key string) { + newMap := make(map[string]string) + for k, v := range envMap { + if k != key { + newMap[k] = v + } + } + saveToFile(newMap) + } + + handleSave := func(key, value string) { + newMap := make(map[string]string) + for k, v := range envMap { + newMap[k] = v + } + newMap[key] = value + saveToFile(newMap) + setEditingKey("") + setHighlightKey(key) + } + + handleCancel := func() { + setEditingKey("") + } + + // Get sorted keys + var keys []string + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + + return vdom.H("div", map[string]any{ + "className": "env-editor", + }, + Header(HeaderProps{ + Path: envPath, + OnAddNew: handleAdd, + IsEditing: editingKey != "", + }), + + vdom.If(error != "", + vdom.H("div", map[string]any{ + "className": "env-error", + }, error), + ), + + vdom.H("div", map[string]any{ + "className": "env-list", + }, + vdom.ForEach(keys, func(key string) any { + return EnvItem(EnvItemProps{ + Key: key, + Value: envMap[key], + OnEdit: func() { handleEdit(key) }, + OnDelete: func() { handleDelete(key) }, + OnSave: handleSave, // Add this + OnCancel: handleCancel, // Add this + IsEditing: key == editingKey, + Highlight: key == highlightKey, + }) + }), + // Add new item form at bottom + vdom.If(editingKey == "__new__", + EditForm(EditFormProps{ + Key: "", + Value: "", + IsNew: true, + OnSave: handleSave, + OnCancel: handleCancel, + }), + ), + ), + ) + }, +) + +func main() { + AppClient.RegisterDefaultFlags() + flag.Parse() + + if flag.NArg() != 1 { + fmt.Fprintf(os.Stderr, "Usage: env-editor [flags] \n") + flag.PrintDefaults() + os.Exit(1) + } + + envPath = flag.Arg(0) + + // Verify directory exists + dir := filepath.Dir(envPath) + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + fmt.Fprintf(os.Stderr, "Directory does not exist: %s\n", dir) + os.Exit(1) + } + + AppClient.RunMain() +} diff --git a/envedit/style.css b/envedit/style.css new file mode 100644 index 0000000..ba62277 --- /dev/null +++ b/envedit/style.css @@ -0,0 +1,203 @@ +.env-editor { + padding: 1rem; + max-width: 800px; + margin: 0 auto; +} + +.env-header { + margin-bottom: 2rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.env-header h1 { + margin: 0; + margin-right: auto; +} + +.env-path { + color: #888; + font-family: monospace; +} + +.env-error { + background-color: #441111; + color: #ff6666; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 4px; +} + +.env-list { + margin-bottom: 1rem; +} + +.env-item { + display: grid; + grid-template-columns: 200px 1fr auto; + gap: 1rem; + padding: 0.5rem; + margin-bottom: 0.5rem; + background-color: rgba(255, 255, 255, 0.05); + border-radius: 4px; + min-height: 2rem; + align-items: center; + transition: background-color 0.3s ease; +} + +.env-item.highlight { + background-color: rgba(102, 255, 102, 0.15); +} + +.env-item-editing { + align-items: start; +} + +.env-item-key { + font-family: monospace; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; +} + +.env-item-value { + font-family: monospace; + white-space: pre-wrap; + word-break: break-all; +} + +.env-item-actions { + display: flex; + gap: 0.5rem; +} + +.env-item button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: none; + border: 1px solid #666; + border-radius: 3px; + color: #ccc; + cursor: pointer; +} + +.env-item button:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.env-item-delete:hover { + color: #ff6666; + border-color: #ff6666; +} + +.env-edit-key { + width: 100%; + padding: 0.5rem; + font-family: monospace; + background-color: rgba(0, 0, 0, 0.2); + border: 1px solid #666; + border-radius: 3px; + color: #fff; +} + +.env-key-static { + padding: 0.5rem; + font-family: monospace; + font-weight: bold; +} + +.env-edit-value { + width: 100%; + padding: 0.5rem; + font-family: monospace; + background-color: rgba(0, 0, 0, 0.2); + border: 1px solid #666; + border-radius: 3px; + color: #fff; + resize: vertical; + min-height: 4.5rem; +} + +.env-edit-error { + color: #ff6666; + grid-column: 1 / -1; + margin-top: 0.5rem; +} + +/* Updated button styles */ +.env-edit-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; + min-height: 32px; +} + +.env-edit-save, +.env-edit-cancel { + height: 32px; + padding: 0 1rem; + background: none; + border: 1px solid #666; + border-radius: 3px; + color: #ccc; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-size: 14px; + min-width: 80px; +} + +.env-edit-save { + border-color: #66ff66; + color: #66ff66; +} + +.env-edit-save:hover { + background-color: rgba(100, 255, 100, 0.1); +} + +.env-edit-cancel:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.env-edit-save i { + font-size: 12px; +} + +.env-add { + padding: 0.5rem 1rem; + background: none; + border: 1px solid #66ff66; + border-radius: 3px; + color: #66ff66; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.env-add:hover { + background-color: rgba(100, 255, 100, 0.1); +} + +.env-add i { + font-size: 0.875em; +} + +.env-add-active { + border-color: #666; + color: #ccc; +} + +.env-add-active:hover { + background-color: rgba(255, 255, 255, 0.1); +} \ No newline at end of file