Compare commits

..

2 Commits

Author SHA1 Message Date
befa9795c1 Update main.go 2026-03-28 12:47:38 +01:00
c5695af7fd Add custom open command override 2026-03-28 12:47:37 +01:00
4 changed files with 120 additions and 13 deletions

View File

@@ -13,6 +13,7 @@ const appName = "luxtools-client"
// Config stores client configuration loaded from disk. // Config stores client configuration loaded from disk.
type Config struct { type Config struct {
PathMap map[string]string `json:"path_map"` PathMap map[string]string `json:"path_map"`
OpenLocationCommand string `json:"open_location_command,omitempty"`
} }
// ConfigPath returns the full path to the config.json file. // ConfigPath returns the full path to the config.json file.

View File

@@ -0,0 +1,72 @@
package openfolder
import (
"errors"
"os/exec"
"strings"
)
// OpenLocationCustom executes a user-defined command string, substituting %1
// with the given path. The command string is split into executable + arguments
// using shell-like quoting rules (double quotes are respected).
//
// Example command: doublecmd.exe -C -T -P L -L "%1"
func OpenLocationCustom(command string, path string) error {
expanded := strings.ReplaceAll(command, "%1", path)
args, err := splitCommand(expanded)
if err != nil {
return err
}
if len(args) == 0 {
return errors.New("open_location_command: empty command after expansion")
}
return exec.Command(args[0], args[1:]...).Start()
}
// splitCommand splits a command string into tokens, respecting double-quoted
// segments. Quotes are removed from the resulting tokens. Backslash escaping
// of a double quote (\") inside a quoted segment is supported.
func splitCommand(s string) ([]string, error) {
var tokens []string
var current strings.Builder
inQuote := false
hasToken := false
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case ch == '"':
inQuote = !inQuote
hasToken = true // even empty quotes produce a token part
case ch == '\\' && inQuote && i+1 < len(s) && s[i+1] == '"':
// escaped quote inside a quoted segment
current.WriteByte('"')
i++ // skip the next quote
case (ch == ' ' || ch == '\t') && !inQuote:
if hasToken {
tokens = append(tokens, current.String())
current.Reset()
hasToken = false
}
default:
current.WriteByte(ch)
hasToken = true
}
}
if inQuote {
return nil, errors.New("open_location_command: unterminated double quote")
}
if hasToken {
tokens = append(tokens, current.String())
}
return tokens, nil
}

View File

@@ -60,7 +60,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
</head> </head>
<body> <body>
<h1>Path Aliases</h1> <h1>Path Aliases</h1>
<p class="small">Define aliases like <code>PROJECTS</code> -> <code>/mnt/projects</code>. Use in <code>/open</code> as <code>PROJECTS&gt;my/repo</code>.</p> <p class="small">Define aliases like <code>PROJECTS</code> -&gt; <code>/mnt/projects</code>. Use in <code>/open</code> as <code>PROJECTS&gt;my/repo</code>.</p>
<table> <table>
<thead> <thead>
@@ -70,7 +70,19 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
</table> </table>
<div style="margin-top: 0.75rem;"> <div style="margin-top: 0.75rem;">
<button id="addRow">Add Alias</button> <button id="addRow">Add Alias</button>
<button id="save">Save</button> </div>
<h2 style="margin-top: 2rem;">Open Location Command</h2>
<p class="small">
Optionally override the default file manager. Use <code>%1</code> for the resolved path.<br>
Example: <code>doublecmd.exe -C -T -P L -L "%1"</code>
</p>
<div style="max-width: 900px;">
<input type="text" id="openCmd" placeholder='e.g. doublecmd.exe -C -T -P L -L "%1"' style="width: 100%; box-sizing: border-box; padding: 0.35rem;">
</div>
<div style="margin-top: 1rem;">
<button id="save">Save All Settings</button>
</div> </div>
<div id="status" class="small"></div> <div id="status" class="small"></div>
@@ -148,6 +160,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
const keys = Object.keys(data.path_map || {}).sort(); const keys = Object.keys(data.path_map || {}).sort();
if (keys.length === 0) addRow(); if (keys.length === 0) addRow();
for (const k of keys) addRow(k, data.path_map[k]); for (const k of keys) addRow(k, data.path_map[k]);
document.getElementById('openCmd').value = data.open_location_command || '';
setStatus('Loaded', false); setStatus('Loaded', false);
} }
@@ -155,10 +168,11 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
try { try {
const map = collect(); const map = collect();
setStatus('Saving...', false); setStatus('Saving...', false);
const openCmd = document.getElementById('openCmd').value.trim();
const res = await fetch('/settings/config', { const res = await fetch('/settings/config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path_map: map }) body: JSON.stringify({ path_map: map, open_location_command: openCmd })
}); });
const data = await res.json(); const data = await res.json();
if (!data.ok) { if (!data.ok) {

36
main.go
View File

@@ -63,6 +63,7 @@ type openResponse struct {
type settingsConfig struct { type settingsConfig struct {
PathMap map[string]string `json:"path_map"` PathMap map[string]string `json:"path_map"`
OpenLocationCommand string `json:"open_location_command"`
} }
type configStore struct { type configStore struct {
@@ -84,7 +85,10 @@ func newConfigStore() (*configStore, error) {
func (s *configStore) Snapshot() config.Config { func (s *configStore) Snapshot() config.Config {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
return config.Config{PathMap: clonePathMap(s.cfg.PathMap)} return config.Config{
PathMap: clonePathMap(s.cfg.PathMap),
OpenLocationCommand: s.cfg.OpenLocationCommand,
}
} }
func (s *configStore) Update(cfg config.Config) error { func (s *configStore) Update(cfg config.Config) error {
@@ -292,7 +296,11 @@ func main() {
case http.MethodGet: case http.MethodGet:
cfg := configStore.Snapshot() cfg := configStore.Snapshot()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "path_map": cfg.PathMap}) _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"path_map": cfg.PathMap,
"open_location_command": cfg.OpenLocationCommand,
})
return return
case http.MethodPost, http.MethodPut: case http.MethodPost, http.MethodPut:
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024)) dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024))
@@ -309,12 +317,18 @@ func main() {
return return
} }
if err := configStore.Update(config.Config{PathMap: pathMap}); err != nil { openCmd := strings.TrimSpace(req.OpenLocationCommand)
if err := configStore.Update(config.Config{PathMap: pathMap, OpenLocationCommand: openCmd}); err != nil {
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()}) writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()})
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "path_map": pathMap}) _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"path_map": pathMap,
"open_location_command": openCmd,
})
return return
default: default:
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
@@ -372,10 +386,16 @@ func main() {
return return
} }
if err := openfolder.OpenLocation(target); err != nil { var openErr error
errLog.Printf("/open open-failed method=%s path=%q normalized=%q err=%v dur=%s", r.Method, rawPath, target, err, time.Since(start)) if customCmd := configStore.Snapshot().OpenLocationCommand; customCmd != "" {
notify.Show("luxtools-client", fmt.Sprintf("Failed to open: %s (%v)", target, err)) openErr = openfolder.OpenLocationCustom(customCmd, target)
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()}) } else {
openErr = openfolder.OpenLocation(target)
}
if openErr != nil {
errLog.Printf("/open open-failed method=%s path=%q normalized=%q err=%v dur=%s", r.Method, rawPath, target, openErr, time.Since(start))
notify.Show("luxtools-client", fmt.Sprintf("Failed to open: %s (%v)", target, openErr))
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: openErr.Error()})
return return
} }
infoLog.Printf("/open opened method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start)) infoLog.Printf("/open opened method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start))