diff --git a/cmd/companion/commands.go b/cmd/companion/commands.go
new file mode 100644
index 0000000..6d2c647
--- /dev/null
+++ b/cmd/companion/commands.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os/exec"
+ "runtime"
+ "strings"
+)
+
+// commandDefaults holds the per-OS open-command templates used when the user
+// hasn't overridden them in the config.
+type commandDefaults struct {
+ OpenFile string
+ OpenFolder string
+}
+
+func defaultCommands() commandDefaults {
+ if runtime.GOOS == "windows" {
+ return commandDefaults{
+ OpenFile: `cmd /c start "" "{path}"`,
+ OpenFolder: `explorer.exe "{path}"`,
+ }
+ }
+ return commandDefaults{
+ OpenFile: `xdg-open "{path}"`,
+ OpenFolder: `xdg-open "{path}"`,
+ }
+}
+
+// resolveOpenCommand returns the user-configured command if non-blank, else
+// the platform default.
+func resolveOpenCommand(configured, fallback string) string {
+ if strings.TrimSpace(configured) != "" {
+ return configured
+ }
+ return fallback
+}
+
+// runOpenCommand tokenizes template, substitutes {path} with the resolved
+// path (appending it if the placeholder is missing), and starts the command.
+func runOpenCommand(template, path string) error {
+ tokens, err := tokenizeCommand(template)
+ if err != nil {
+ return fmt.Errorf("parse command: %w", err)
+ }
+ if len(tokens) == 0 {
+ return errors.New("command is empty")
+ }
+ sawPath := false
+ for i, t := range tokens {
+ if strings.Contains(t, "{path}") {
+ tokens[i] = strings.ReplaceAll(t, "{path}", path)
+ sawPath = true
+ }
+ }
+ if !sawPath {
+ tokens = append(tokens, path)
+ }
+ return exec.Command(tokens[0], tokens[1:]...).Start()
+}
+
+// tokenizeCommand splits a command-line string into argv tokens, honouring
+// double-quoted segments. An empty pair "" yields an empty argument — needed
+// for Windows `cmd /c start "" file`, where the empty quotes are the title.
+func tokenizeCommand(s string) ([]string, error) {
+ var tokens []string
+ var cur strings.Builder
+ inQuote := false
+ inToken := false
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c == '"' {
+ inQuote = !inQuote
+ inToken = true
+ continue
+ }
+ if !inQuote && (c == ' ' || c == '\t') {
+ if inToken {
+ tokens = append(tokens, cur.String())
+ cur.Reset()
+ inToken = false
+ }
+ continue
+ }
+ cur.WriteByte(c)
+ inToken = true
+ }
+ if inQuote {
+ return nil, errors.New("unclosed quote")
+ }
+ if inToken {
+ tokens = append(tokens, cur.String())
+ }
+ return tokens, nil
+}
diff --git a/cmd/companion/config.go b/cmd/companion/config.go
index a2ba4e2..ea94e1a 100644
--- a/cmd/companion/config.go
+++ b/cmd/companion/config.go
@@ -12,9 +12,11 @@ import (
const defaultPort = 17680
type config struct {
- WikiRoot string `json:"wikiRoot"`
- AllowedOrigins []string `json:"allowedOrigins"`
- Port int `json:"port,omitempty"`
+ WikiRoot string `json:"wikiRoot"`
+ AllowedOrigins []string `json:"allowedOrigins"`
+ Port int `json:"port,omitempty"`
+ OpenFileCommand string `json:"openFileCommand,omitempty"`
+ OpenFolderCommand string `json:"openFolderCommand,omitempty"`
}
// configPath returns the platform-conventional config path.
diff --git a/cmd/companion/config.html b/cmd/companion/config.html
index 7221630..df5b67e 100644
--- a/cmd/companion/config.html
+++ b/cmd/companion/config.html
@@ -49,6 +49,17 @@
margin-top: 1.25rem;
}
button:hover { background: var(--primary-hover); }
+ .btn-small {
+ padding: 0.4rem 0.75rem;
+ font-size: 0.8rem;
+ margin-top: 0;
+ }
+ .input-row {
+ display: flex;
+ gap: 0.5rem;
+ align-items: stretch;
+ }
+ .input-row input { flex: 1; }
.notice {
background: var(--bg-panel);
border-left: 3px solid var(--secondary);
@@ -58,6 +69,23 @@
.meta { margin-top: 2rem; font-size: 0.8rem; color: var(--text-muted); border-top: 1px dashed var(--secondary); padding-top: 0.75rem; }
.meta div { margin: 0.2rem 0; }
code { color: var(--link); word-break: break-all; }
+ .log {
+ margin-top: 2rem;
+ border-top: 1px dashed var(--secondary);
+ padding-top: 0.75rem;
+ }
+ .log h2 { font-size: 0.95rem; margin: 0 0 0.5rem; }
+ .log pre {
+ background: var(--bg-panel);
+ border: 1px solid var(--secondary);
+ margin: 0;
+ padding: 0.5rem 0.75rem;
+ max-height: 20rem;
+ overflow: auto;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ word-break: break-all;
+ }
@@ -78,13 +106,48 @@
One origin per line (scheme + host + optional port, no trailing slash). Only browser tabs from these origins can ask the companion to open files.
+
+
+
+
+
+
Run when the wiki asks to open a file. Use {{`{path}`}} for the resolved file path. Leave blank to use the default. Default: {{.DefaultOpenFileCommand}}
+
+
+
+
+
+
+
Run when the wiki asks to reveal a folder. Default: {{.DefaultOpenFolderCommand}}
+
+
+
Config file: {{.ConfigPath}}
+
Log file: {{.LogPath}}
Port is set in the config file only; restart after editing.