Compare commits
2 Commits
8e369ebd5a
...
befa9795c1
| Author | SHA1 | Date | |
|---|---|---|---|
| befa9795c1 | |||
| c5695af7fd |
@@ -12,7 +12,8 @@ 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.
|
||||||
|
|||||||
72
internal/openfolder/custom.go
Normal file
72
internal/openfolder/custom.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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>my/repo</code>.</p>
|
<p class="small">Define aliases like <code>PROJECTS</code> -> <code>/mnt/projects</code>. Use in <code>/open</code> as <code>PROJECTS>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) {
|
||||||
|
|||||||
38
main.go
38
main.go
@@ -62,7 +62,8 @@ 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))
|
||||||
|
|||||||
Reference in New Issue
Block a user