refactor(ui): decouple tui from slack I/O and core - #212
Conversation
Characterizes every place the TUI hands work to the rest of the app: presence, typing, theme and width saves, uploads, workspace switching, unread readers and the status reporter, avatars, clipboard paste (text, image, file path), thread export, image preview fetch, and the channel/message/thread service calls whose results the reducers rewrite. Plus the custom theme loader. These are the regression net for moving the collaborators behind internal/core ports. Scenarios live in seams_test.go; how each collaborator is installed lives in seams_wiring_test.go, which is the only file the refactor may change.
…core The ports the TUI will depend on need to name these types, and a port package can't import the TUI. Move the definitions verbatim and leave aliases behind, so neither side changes how it spells them: - messages: MessageItem, Attachment, ThumbSpec, ReactionItem - blockkit's Block Kit data (now internal/core/blocks) - channelfinder.Item, reactionpicker.EmojiEntry, compose.PendingAttachment - presencemenu.Action, themeswitcher.ThemeScope - cache.ReadState, cache.ThreadSummary, config.Theme With the last three in core the TUI no longer imports internal/cache or internal/config at all. blockkit's sealed blockType() marker moved with the types, so render.go and one test reach it through blocks.TypeName instead.
ChannelService, MessageService, ThreadService, ReactionService and SearchService, with their closure-bundle constructors, now live in core, so the TUI depends on ports it doesn't own and cmd/slk builds them without going through the TUI package. core can't name tea.Msg (a defined type in ultraviolet), so the ports return core.Msg (= any) and core.Cmd (= func() core.Msg). Closure bodies in main.go are unchanged; only their result types moved. The three call sites that need a tea.Cmd go through teaCmd, which keeps nil nil so the existing `c != nil` guards still hold. The per-method test helpers used to read the installed adapter's closures back by type assertion; the adapter is unexported in core now, so they remember what each test App was wired with instead.
The App still took a dozen bare callbacks, a concrete *filedl.Downloader and *image.Fetcher, and made its own OS calls (exec for links and files, the native and wl-paste clipboards, os.Stat for paste-a-path, the thread export's MkdirAll/WriteFile). They now go through services in core: FileService upload, download DesktopService open, clipboard, stat, thread export, status command PresenceService set status, typing SettingsService theme, sidebar width UnreadService sidebar and workspace-rail read state WorkspaceService switch AvatarService rendered avatars ImageFetcher what *image.Fetcher already provides cmd/slk wires them from the same closures as before; only the calls that install them changed. launchOS and the clipboard readers moved to cmd/slk, the export write to internal/export.SaveThread. Services default to nil where the old callbacks were nil-checked, so "not wired" behaves as before. DesktopService defaults to a no-op instead, because its callbacks used to fall back to the real OS, and an unwired Stat reports files as missing rather than returning (nil, nil). The two paste-a-path tests that leaned on that real fallback now wire os.Stat explicitly.
LoadCustomThemes did its own os.ReadDir/os.ReadFile. It now takes an fs.FS and main passes os.DirFS(themesDir), so the TUI no longer touches the filesystem itself. Same listing order, same skips, same silent return when the directory is missing.
Ctrl+E's temp file and editor process move verbatim to internal/editor, which cmd/slk wires as the new core.EditorService. The TUI keeps the policy: which compose box, the lock, the toasts, and trusting the file over a non-zero exit, now recognised by the error's ExitCode method rather than its *exec.ExitError type.
Fails when a non-test file under internal/ui imports infrastructure (the Slack client, slackhttp, cache, config, filedl, export, net/http, os/exec, the native clipboard, ...) or calls the filesystem, or names the concrete image fetcher; and when internal/core imports the TUI or I/O packages. slack-go stays allowed in blockkit, whose input it is. Run against the tree before this series it reports the 22 places the TUI used to do its own I/O.
Nothing wires typing that way; golangci-lint flagged it.
The UI invariant named internal/ui/services.go and five service interfaces; the ports now live in internal/core and the boundary has a test. Also list the per-method service helpers tests use.
|
I'll mark this ready for review once I've finished manual testing (the checklist in the description). This is a first step rather than a full cleanup. It moves the TUI's I/O behind interfaces in Suggestions welcome. I know this will conflict with some open PRs; #109 is the one that needs real changes, since its new service belongs in |
|
@gammons the PR is ready from my side, i have tested the scenarios listed in the description manually, the test suite is green. please review and let me know if you want me to explain something. Your review and suggestions are highly welcome. |
|
Thanks for this — it's a well-built PR, and the verification story is the best part. Before getting into specifics I should give you some context you don't have. Context: there's a refactor plan in flightI've been working with an agent over the last week on the parts of the architecture that hurt most. The output is on
Phase 0 is done. Phases 1–6 hadn't started, which is why this PR doesn't collide with anything. Worth reading the tracking doc if you're planning more of this — not to constrain you, but so we're not solving the same thing twice from different directions. That's the failure mode I'm most worried about right now. Your "Phase 2" vs the plan's Phase 2 — partial overlapYou wrote: "the implementations are still closures inside That maps almost exactly onto the plan's Phase 2, step 6: extract Two things you'd hit that the plan already scoped, though: A prerequisite. Three live data races in the same code. Phase 2 steps 1–3 are not cleanup, they're bug fixes:
The doc comment at And you've found a gap in the plan. Your other deferral — "the engine still constructs One note on the net effect here: You corrected something I got wrongThe invariant I had in The original claim was narrowly true about networking imports in the root package, and I over-generalised it to "does no I/O." Your rewrite is right, and backing it with an enforcing test instead of prose is exactly the convention the file asks for. Thanks for maintaining the helper table too. What I verifiedI didn't take the claims on trust — the regression argument is the whole review here, so:
The frozen-seam-test technique — land the scenarios first so they pass on Three things I'd like addressed1. I don't think this blocks the PR — parallel core-owned types for the whole image pipeline is a real cost and probably not worth paying today. But a test whose name over-promises is how a boundary quietly erodes. Either add 2. Is 3. The five type aliases ( AlsoTwo production bugs surfaced during Phase 0 that are adjacent to code you touched, so you may bump into them:
Neither is yours to fix here. Approving with the three comments above — none of them blocks merging, and I'd rather land this and iterate than hold it. Nice work on the verification discipline; it's the part I'd most like to see repeated. |
Interfaces (ports) and their Funcs/adapter/constructor plumbing were in one 981-line file. Pure move, no behavior change: ports.go holds the 14 service interfaces and ClipboardFormat; adapters.go holds the Funcs bundles, adapter structs, constructors, and closure typedefs.
It named itself for a guarantee it didn't enforce: core imports internal/image, which itself reaches net/http and os. Rename to TestCoreDoesNotImportTUIOrDirectIO and document the image port as a deliberate exemption instead of silently allowing it under a misleading name.
|
Thanks for your review. I have fixed your concerns and implemented your suggestions.
This should be good for merge now. |
|
Looks great. thank you @laraibg786 ! |
internal/uino longer does I/O itself. Every call it makes to Slack, SQLite, the filesystem, the clipboard, the external editor, or the OS goes through an interface in the newinternal/corepackage, andcmd/slkwires the implementations. A test enforces the boundary.Nothing changes for the user. There are no bug fixes and no new features. The diff is code motion, type aliases and rewiring.
Why
Diagnosis: when something breaks between the TUI and the app, the suspects are the methods on one interface, not closures spread across
app.goandmain.go.Testing: the App can be tested with fakes for exactly the methods a test cares about, with no real filesystem, clipboard or process.
Fewer conflicts: new I/O has one obvious place to go, a port in
core/services.go.boundary_test.gofails any change that reaches past it.What moved
internal/core)ChannelService,MessageService,ThreadService,ReactionService,SearchServiceinternal/ui/services.gomain.goclosures (unchanged bodies)FileServiceSetUploader,SetFileDownloaderfiledlDesktopServiceSetClipboardReader,SetStatusReporter, thelaunchOS/os.Statdefaults, the thread-export writerlaunchOS(moved tocmd/slk), clipboard readers,os.Stat,export.SaveThreadEditorServiceexec.Cmdinternal/editor(verbatim move)PresenceServiceSetStatusSetter,SetTypingSendermain.goclosuresSettingsServiceSetThemeSaver,SetWidthSavermain.goclosuresUnreadServiceSetReadStateReader,SetWorkspaceUnreadReadermain.goclosuresWorkspaceServiceSetWorkspaceSwitchermain.goclosureAvatarServiceSetAvatarFuncImageFetcher*image.Fetcherfield*image.FetcherThe value types shared by the TUI and the app (
MessageItem,ReadState,Theme,PendingAttachment, Block Kit types, and so on) now live incore. Their old names stay as type aliases, so call sites don't change.Commits
Each commit builds and passes the full suite on its own. They are best reviewed in order, with
git show --color-moved.test(ui): pin the TUI's collaborator seamsmain: 36 + 2 scenarios that drive the App the way a user would and pin what crosses each seamrefactor(core): move the data the TUI and engine sharecore, aliases left behindrefactor(core): move the five service portsui/services.go→core/services.go,tea.Msg→core.Msgrefactor(ui): put the TUI's remaining collaborators behind core portsmain.gorewiringrefactor(styles): read custom theme files through fs.FSLoadCustomThemes(fsys fs.FS)refactor(ui): run the external editor through a core portinternal/editortest(ui): guard the TUI/core boundaryboundary_test.gotest(ui): drop an unused typing-sender helperdocs: point AGENTS.md at internal/coreHow regressions are ruled out
seams_test.goandstyles/seams_test.goland first and pass onmain, then stay byte-identical through every later commit. The wiring they go through lives inseams_wiring_test.go, the only test file the refactor commits rewrite.mainit reports 28 violations; on this branch it reports none.testdata/goldenisn't in the diff.Not changed on purpose
TMUX,os.UserHomeDir,$VISUAL/$EDITOR) stay in the TUI. It reads its own terminal; the boundary test allows them.uimessage types, and the implementations are still closures insiderun(). Extracting them is Phase 2 work.launchOSand the clipboard readers moved verbatim and are still untested, as before.Manual test plan
These are the paths that changed wiring. Each should behave the same as on
main:Ctrl+V, thenEnter, in a channel and in a thread: it uploadsdon a message with a file: it downloads and opensoon a message with a link opens the browser;O/vpreview, thenEnter, opens the system viewerSin a thread saves the Markdown exportCtrl+Eopens$EDITOR; the saved text replaces the draft; unset editor shows the toast[/]sidebar width survives a restart1–9and by clicking the rail(N) +Mandstatus_commandupdate