Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
!demo/demo-package.zip
package/
demo.mp4
.idea/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ Reports land in your OS cache directory, timestamped, next to the resume log:

Use `--report PATH` to write it somewhere specific, or `DISCORD_DELETE_STATE_DIR` to move the whole directory.

The final screen prints that path with an `↗ open report` button. Click it, or press `o`, to open the report in whatever handles `.txt`. It is an OSC 8 hyperlink too, for terminals that follow those on ctrl+click (cmd+click on macOS).

The mouse is captured only on that final frame, so drag-to-select works as usual during a run; hold shift to select there.

That directory also holds the resume log, `<key>.deleted.log`, one confirmed-gone ID per line. It is keyed by account and therefore works across packages. Delete the log to start over from scratch.

The TUI also saves its settings to `<key>.config.json` in that directory, so the next run for that account starts where you left off. The token is not saved here. Any explicit flag overrides a saved value, and headless runs ignore the file.
Expand Down
64 changes: 59 additions & 5 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ type appModel struct {
reportPath string // where the run report was written ("" = not yet / failed)
reportOverride string // --report path; "" = default alongside the resume log
notifyResult string // short outcome of the ntfy ping, shown on the final frame
reportHit hitBox // where the final frame's open button sits, for click handling
openErr string // last failure from opening the report

width, height int
quitting bool
Expand Down Expand Up @@ -654,6 +656,7 @@ func (m *appModel) launchEngine() (tea.Model, tea.Cmd) {
m.reported = false
m.stopping, m.pausePend = false, false
m.reportPath, m.notifyResult, m.logWarn = "", "", ""
m.reportHit, m.openErr = hitBox{}, ""
m.screen = scRunning

// Remote control (pause/resume/stop from the phone) rides the same ntfy
Expand Down Expand Up @@ -896,12 +899,34 @@ func (m *appModel) finalizeRun() tea.Cmd {
Results: m.phaseResults,
Resumed: m.resumed,
}
cmds := []tea.Cmd{notifyCmd(resolveNtfyURL(m.cfg.ntfy), r)}
if path := r.destPath(m.reportOverride, m.reportProgPath()); path != "" {
if err := writeRunReport(path, r); err == nil {
m.reportPath = path
// Only now: during the run the terminal keeps the mouse, so
// selecting text works as usual.
cmds = append(cmds, tea.EnableMouseCellMotion)
}
}
return notifyCmd(resolveNtfyURL(m.cfg.ntfy), r)
return tea.Batch(cmds...)
}

// clickReport answers a plain click on the final frame's button, which the OSC 8
// link cannot: terminals reserve that for ctrl/cmd+click.
func (m *appModel) clickReport(msg tea.MouseMsg) {
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
return
}
if m.reportPath != "" && m.reportHit.contains(msg.X, msg.Y) {
m.openReport()
}
}

func (m *appModel) openReport() {
m.openErr = ""
if err := openFile(m.reportPath); err != nil {
m.openErr = "could not open " + m.reportPath + ": " + err.Error()
}
}

// reportProgPath is the resume log the report path is derived from: the message
Expand Down Expand Up @@ -1028,6 +1053,10 @@ func (m *appModel) viewConfirm() string {
// --- running ---------------------------------------------------------------

func (m *appModel) updateRunning(msg tea.Msg) (tea.Model, tea.Cmd) {
if mouse, ok := msg.(tea.MouseMsg); ok {
m.clickReport(mouse)
return m, nil
}
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
Expand Down Expand Up @@ -1055,7 +1084,13 @@ func (m *appModel) updateRunning(msg tea.Msg) (tea.Model, tea.Cmd) {
m.finishRun()
m.recompute()
m.screen = scHome
return m, nil
// Hand the mouse back: nothing off this screen is clickable.
m.reportHit, m.openErr = hitBox{}, ""
return m, tea.DisableMouse
}
case "o":
if m.reportPath != "" { // only set once the run has finalized
m.openReport()
}
case "p", " ":
// Pause / resume (no-op once the run is done or in dry run). A manual
Expand Down Expand Up @@ -1140,7 +1175,15 @@ func (m *appModel) viewRunning() string {
b.WriteString(wrapText(stYellow.Render(fmt.Sprintf("⤼ %s message(s) can't be deleted (system messages or servers/DMs you've left). Post-run report has more details.", commafy(n))), m.width, 2) + "\n")
}
if m.reportPath != "" {
b.WriteString(wrapText(stDim.Render("report: "+m.reportPath), m.width, 2) + "\n")
// Wrapped first, linked after: the escapes stay out of the wrap.
b.WriteString(linkPath(m.reportPath,
wrapText(stDim.Render("report: "+m.reportPath), m.width, 2)) + "\n")
line, hit := reportButton(m.reportPath, strings.Count(b.String(), "\n"))
m.reportHit = hit
b.WriteString(line + "\n")
if m.openErr != "" {
b.WriteString(wrapText(stYellow.Render("⚠ "+m.openErr), m.width, 2) + "\n")
}
}
if m.notifyResult != "" {
b.WriteString(wrapText(stDim.Render(m.notifyResult), m.width, 2) + "\n")
Expand All @@ -1152,7 +1195,11 @@ func (m *appModel) viewRunning() string {
case !snap.Completed:
done = stYellow.Render("stopped")
}
b.WriteString(wrapText(done+stDim.Render(" · ")+
keys := ""
if m.reportPath != "" {
keys = stKeyHelp.Render("o") + stDim.Render(" open report ")
}
b.WriteString(wrapText(done+stDim.Render(" · ")+keys+
stKeyHelp.Render("b")+stDim.Render(" home ")+stKeyHelp.Render("q")+stDim.Render(" quit"), m.width, 2) + "\n")
} else {
if m.paused {
Expand All @@ -1172,7 +1219,14 @@ func (m *appModel) viewRunning() string {
}
b.WriteString(wrapText(stKeyHelp.Render(help), m.width, 2) + "\n")
}
return b.String()

out := b.String()
// Bubble Tea paints only the last m.height lines, so the button moves up with
// whatever scrolled off the top.
if lines := strings.Count(out, "\n") + 1; m.height > 0 && lines > m.height {
m.reportHit.y -= lines - m.height
}
return out
}

// engBaseDelay is the current live pacing floor (0 if the engine isn't running).
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ func writePlainReport(in plainRun, cfg runConfig, startedAt time.Time, snaps []p
}
if path := r.destPath(in.reportOverride, in.progPath); path != "" {
if err := writeRunReport(path, r); err == nil {
fmt.Printf("Report written to %s\n", path)
fmt.Printf("Report written to %s\n", plainPathLink(path))
}
}
if target := resolveNtfyURL(cfg.ntfy); target != "" {
Expand Down
20 changes: 20 additions & 0 deletions open.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package main

import (
"os/exec"
"runtime"
)

// openFile hands path to the desktop's handler for its type. A var so tests can
// take a click without a text editor opening on someone's desktop.
var openFile = func(path string) error {
switch runtime.GOOS {
case "windows":
// Not cmd's start builtin: that needs a console window of its own.
return exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", path).Start()
case "darwin":
return exec.Command("open", path).Start()
default:
return exec.Command("xdg-open", path).Start()
}
}
56 changes: 56 additions & 0 deletions tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package main

import (
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
Expand All @@ -11,6 +13,7 @@ import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-isatty"
)

func demoMode() bool { return os.Getenv("DEV_DISCORD_DELETE_DEMO") != "" }
Expand Down Expand Up @@ -81,6 +84,59 @@ func wrapText(s string, width, indent int) string {
return strings.Join(lines, "\n")
}

// osc8 wraps text in an OSC 8 hyperlink: clickable where the terminal supports
// it, and invisible everywhere else.
func osc8(target, text string) string {
return "\x1b]8;;" + target + "\x1b\\" + text + "\x1b]8;;\x1b\\"
}

// fileURL is the file:// form of path. Drive letters need the extra leading
// slash, and a UNC path's server is the URL's host rather than part of its path.
func fileURL(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
p := filepath.ToSlash(abs)
if rest, ok := strings.CutPrefix(p, "//"); ok {
host, tail, _ := strings.Cut(rest, "/")
return (&url.URL{Scheme: "file", Host: host, Path: "/" + tail}).String()
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return (&url.URL{Scheme: "file", Path: p}).String()
}

// linkPath makes already-rendered text open path when clicked.
func linkPath(path, text string) string { return osc8(fileURL(path), text) }

// plainPathLink is linkPath for --no-tui runs, minus the escapes when stdout is
// piped somewhere that would only have to strip them.
func plainPathLink(path string) string {
if !isatty.IsTerminal(os.Stdout.Fd()) {
return path
}
return linkPath(path, path)
}

// hitBox is a clickable region of the frame: row, inclusive column span, 0-based.
type hitBox struct{ y, x0, x1 int }

func (h hitBox) contains(x, y int) bool { return y == h.y && x >= h.x0 && x <= h.x1 }

const (
btnOpenReport = "↗ open report"
btnIndent = 2
)

// reportButton renders the end-of-run button on row y and the cells it covers.
// The OSC 8 link is the fallback: terminals only follow it on ctrl/cmd+click.
func reportButton(path string, y int) (string, hitBox) {
line := strings.Repeat(" ", btnIndent) + linkPath(path, stKeyHelp.Render(btnOpenReport))
return line, hitBox{y: y, x0: btnIndent, x1: btnIndent + lipgloss.Width(btnOpenReport) - 1}
}

// twoColMin is the terminal width below which side-by-side panels stack.
const twoColMin = 78

Expand Down
Loading