Implemented click to open report folder/report file - #2
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds interactive “open report / open folder” affordances to the TUI’s final (completed) running screen by rendering OSC 8 hyperlinks and capturing plain left-clicks to launch the report file or reveal it in the OS file manager.
Changes:
- Render the final-frame report path and “↗ open report / ↗ open folder” as OSC 8 hyperlinks, plus record hit-boxes for plain-click activation.
- Add cross-platform native openers (
explorer.exeon Windows;open/xdg-openelsewhere) and hook click handling into the running screen update loop. - Add tests and documentation describing hyperlink + click behavior.
Reviewed changes
Copilot reviewed 7 out of 15 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tui.go | Adds OSC 8 hyperlink helpers, file:// URL conversion, and button hit-box rendering helpers. |
| app.go | Enables mouse capture on the final frame, handles report-button clicks, and renders clickable report path + buttons. |
| tui_test.go | Adds tests for hyperlinks, hit-box accuracy, click behavior, overflow row shifting, and open-error surfacing. |
| open_windows.go | Implements Windows native open/reveal via Explorer. |
| open_other.go | Implements macOS/Linux/BSD native open/reveal via open / xdg-open. |
| main.go | Prints a clickable (or plain, when redirected) report path for non-TUI output. |
| README.md | Documents clickable report path/buttons and selection behavior on the final frame. |
| .idea/vcs.xml | Adds JetBrains IDE metadata (should not be committed). |
| .idea/modules.xml | Adds JetBrains IDE metadata (should not be committed). |
| .idea/golinter.xml | Adds JetBrains IDE metadata (should not be committed). |
| .idea/go.imports.xml | Adds JetBrains IDE metadata (should not be committed). |
| .idea/discord.xml | Adds JetBrains IDE metadata (should not be committed). |
| .idea/discord-delete.iml | Adds JetBrains IDE metadata (should not be committed). |
| .idea/.gitignore | Adds JetBrains IDE metadata (should not be committed). |
| .gitignore | Minor update; should also ignore .idea/ to prevent IDE files from being re-added. |
Files not reviewed (7)
- .idea/.gitignore: Generated file
- .idea/discord-delete.iml: Generated file
- .idea/discord.xml: Generated file
- .idea/go.imports.xml: Generated file
- .idea/golinter.xml: Generated file
- .idea/modules.xml: Generated file
- .idea/vcs.xml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| m.openErr = "" | ||
| if err := openPath(h.path, h.reveal); err != nil { | ||
| m.openErr = "could not open " + h.path + ": " + err.Error() | ||
| } |
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <project version="4"> | ||
| <component name="VcsDirectoryMappings"> | ||
| <mapping directory="" vcs="Git" /> | ||
| </component> | ||
| </project> No newline at end of file |
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <project version="4"> | ||
| <component name="ProjectModuleManager"> | ||
| <modules> | ||
| <module fileurl="file://$PROJECT_DIR$/.idea/discord-delete.iml" filepath="$PROJECT_DIR$/.idea/discord-delete.iml" /> | ||
| </modules> | ||
| </component> | ||
| </project> No newline at end of file |
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <project version="4"> | ||
| <component name="GoLinterSettings"> | ||
| <option name="customConfigFile" value="$PROJECT_DIR$/.golangci.yml" /> | ||
| <option name="useCustomConfigFile" value="true" /> | ||
| </component> | ||
| </project> No newline at end of file |
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <project version="4"> | ||
| <component name="GoImports"> | ||
| <option name="excludedPackages"> | ||
| <array> | ||
| <option value="golang.org/x/net/context" /> | ||
| </array> | ||
| </option> | ||
| </component> | ||
| </project> No newline at end of file |
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <project version="4"> | ||
| <component name="DiscordProjectSettings"> | ||
| <option name="show" value="PROJECT_FILES" /> | ||
| <option name="description" value="" /> | ||
| <option name="applicationTheme" value="default" /> | ||
| <option name="iconsTheme" value="default" /> | ||
| <option name="button1Title" value="" /> | ||
| <option name="button1Url" value="" /> | ||
| <option name="button2Title" value="" /> | ||
| <option name="button2Url" value="" /> | ||
| <option name="customApplicationId" value="" /> | ||
| </component> | ||
| </project> No newline at end of file |
| # Default ignored files | ||
| /shelf/ | ||
| /workspace.xml | ||
| # Editor-based HTTP Client requests | ||
| /httpRequests/ | ||
| # Ignored default folder with query files | ||
| /queries/ | ||
| # Datasource local storage ignored files | ||
| /dataSources/ | ||
| /dataSources.local.xml |
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <module type="WEB_MODULE" version="4"> | ||
| <component name="GoModuleSettings" enabled="true" /> | ||
| <component name="NewModuleRootManager"> | ||
| <content url="file://$MODULE_DIR$" /> | ||
| <orderEntry type="inheritedJdk" /> | ||
| <orderEntry type="sourceFolder" forTests="false" /> | ||
| </component> | ||
| </module> No newline at end of file |
|
Hi, thanks for the PR. A few things before I can merge this. The On the design: I'd rather this open the report itself in whatever app handles The mouse integration is well done, but a keybind ('o'?) may be a reasonable fallback. Finally, a note on comments. They are more verbose than the rest of the repository, which leans terse and technical: a comment should say something the code does not, and explain why rather than what. You beat me to making a PR before I could write a CONTRIBUTING.md to clarify this. Please match the surrounding files. I'll take a more thorough look at the rest if you can implement these. Thanks again. My implementation.
package main
import (
"errors"
tea "github.com/charmbracelet/bubbletea"
)
// errNoEditor is returned when neither VISUAL nor EDITOR names a program.
var errNoEditor = errors.New("no VISUAL or EDITOR set")
// openDoneMsg carries the outcome of an openReport command.
type openDoneMsg struct{ err error }
// openReport opens the report in the desktop's handler where there is a desktop,
// otherwise the terminal editor.
func openReport(path string) tea.Cmd {
if hasDesktop() {
return func() tea.Msg { return openDoneMsg{openDetached(path)} }
}
ed := terminalEditor(path)
if ed == nil {
return func() tea.Msg { return openDoneMsg{errNoEditor} }
}
return tea.ExecProcess(ed, func(err error) tea.Msg { return openDoneMsg{err} })
}
//go:build !windows
package main
import (
"cmp"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
)
// hasDesktop reports whether a graphical session is reachable. xdg-open gates its
// MIME dispatch on these two variables and falls through to a web browser without
// them. macOS sets neither even with a desktop present.
func hasDesktop() bool {
if runtime.GOOS == "darwin" {
return true
}
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}
// terminalEditor builds the command for $VISUAL or $EDITOR, nil if neither is set.
func terminalEditor(path string) *exec.Cmd {
spec := cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"))
if strings.TrimSpace(spec) == "" {
return nil
}
return exec.Command("/bin/sh", "-c", spec+` "$@"`, "sh", path)
}
// openDetached hands path to the desktop's handler for its type.
func openDetached(path string) error {
name := "xdg-open"
if runtime.GOOS == "darwin" {
name = "open"
}
cmd := exec.Command(name, path)
// xdg-open runs a Terminal=true handler on the caller's tty by design, so the
// child gets its own session to keep it away from the TUI.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return err
}
// xdg-open does not fork the handler off, so the child outlives this call and
// is reaped off the update path.
go func() { _ = cmd.Wait() }()
return nil
}
//go:build windows
package main
import (
"os/exec"
"golang.org/x/sys/windows"
)
// hasDesktop is unconditionally true on Windows, where ShellExecute resolves a
// handler without consulting a display variable.
func hasDesktop() bool { return true }
// terminalEditor is unreachable on Windows, where hasDesktop keeps openReport on
// the desktop branch.
func terminalEditor(string) *exec.Cmd { return nil }
// openDetached asks the shell to open path with its registered handler. Going
// through ShellExecute rather than cmd.exe leaves & and ^ in the path needing no
// escaping, and spawns no child to reap.
func openDetached(path string) error {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
return windows.ShellExecute(0, nil, p, nil, nil, windows.SW_SHOWNORMAL)
} |
The end-of-run frame prints the report path as an OSC 8 link and draws an open button next to it. Terminals only follow hyperlinks on ctrl/cmd+click, so the frame records where the button sits and the mouse is captured for that frame alone, leaving text selection alone during the run. Pressing o does the same for terminals that report no mouse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2d2ad59 to
40b7dfa
Compare
|
Hi, thanks again for your PR. I've just pushed it to main with you credited as co-author on the commit. I swapped the opener and adjusted a few things around your code and the documentation. Closing this since it's in. |
No description provided.