Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,26 @@ export function RestoreSummonHotkey(): $CancellablePromise<void> {
return $Call.ByID(442997791);
}

/**
* SaveTextFile prompts with the OS-native save dialog (suggestedName
* pre-filled) and writes content to whatever path the user picks --
* the desktop-mode counterpart to a browser's own download prompt,
* which the Wails webview does not provide (an anchor-element download
* click is silently inert there). Returns the chosen path, or "" with
* a nil error when the user cancels. Server mode and any other run
* without a live windowed application (a headless `go test`, in
* particular) return a nil app from application.Get() -- the same
* no-op guard atlasservice_share.go's revealPath uses -- and this
* method reports that as an error rather than a silent no-op, since
* unlike an OS-reveal action a caller genuinely needs to know the
* save never happened. A real `-tags server` build additionally
* degrades through Wails3's own server-mode dialog stub, which
* already returns an equivalent "not available" error.
*/
export function SaveTextFile(suggestedName: string, content: string): $CancellablePromise<string> {
return $Call.ByID(891957520, suggestedName, content);
}

/**
* SetAttentionIdleThreshold persists seconds (a non-positive value
* resets to the default, mirroring a cleared Settings field rather than
Expand Down
15 changes: 4 additions & 11 deletions frontend/src/composition/CompositionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { InventoryList, type InventoryItem } from '../shared/InventoryList'
import { ENTITY_ICON } from '../shared/entityIcons'
import { formatUpdated, sortByUpdatedDesc } from '../shared/inventorySort'
import { describeSeedReset } from '../shared/seedLifecycle'
import { downloadJSON } from '../shared/downloadJSON'
import { RestoreExamplesButton } from '../shared/RestoreExamplesButton'
import { useImportConfirm } from '../shared/useImportConfirm'
import type { Workflow } from '../../bindings/github.com/alicoding/mill/internal/domain/composition/models'
Expand Down Expand Up @@ -216,19 +217,11 @@ function CompositionView() {

// Downloads id's current definition as a portable .json file --
// ExportWorkflow's own doc comment covers why the output is
// deterministic. A Blob + synthetic anchor click is the standard
// browser download mechanism, identical inside the Wails webview.
// deterministic; downloadJSON routes the actual save through the
// right mechanism for the current runtime.
const exportWorkflow = (id: string, label: string) => {
CompositionService.ExportWorkflow(id)
.then((json) => {
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${label.trim() || 'workflow'}.json`
a.click()
URL.revokeObjectURL(url)
})
.then((json) => downloadJSON(`${label.trim() || 'workflow'}.json`, json))
.catch((err) => setImportError(String(err)))
}

Expand Down
31 changes: 19 additions & 12 deletions frontend/src/shared/downloadJSON.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
// The standard browser Blob + synthetic-anchor-click download mechanism,
// used identically by every Configure entity's Export button
// (ConfigureRequests/Lists/MCPServers.tsx) -- extracted once the same
// ~8 lines were about to be written a third time (CLAUDE.md: three
// similar lines is fine, a fourth repetition is the actual signal to
// share it). Works the same inside Mill's own Wails webview as it does
// in a normal browser tab, no native file-save API needed. Composition's
// own Export button (CompositionView.tsx, built first) keeps its
// original inline version rather than being retrofitted to this --
// already shipped and tested, and churning it for a marginal DRY gain
// isn't worth the re-verification cost.
export function downloadJSON(filename: string, json: string) {
import { SettingsService } from './bindings'

// Every Export button's download mechanism (Atlas toolbar, Configure
// Requests/Lists/MCPServers, and CompositionView's own workflow
// export) routes through this one function. A synthetic anchor-click
// download needs the browser to supply a download handler -- the
// Wails webview doesn't provide one, so the anchor click is silently
// inert there even though nothing about it errors. Desktop mode
// therefore goes through SettingsService.SaveTextFile (the OS-native
// save dialog); server mode (a real browser tab) keeps the standard
// Blob + synthetic-anchor-click path, which works there exactly as it
// does in any other web app.
export async function downloadJSON(filename: string, json: string): Promise<void> {
const buildInfo = await SettingsService.GetBuildInfo().catch(() => null)
const isNativeWebview = buildInfo != null && !buildInfo.Server
if (isNativeWebview) {
await SettingsService.SaveTextFile(filename, json)
return
}
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
Expand Down
40 changes: 40 additions & 0 deletions internal/services/settingssvc/settingsservice_savefile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package settingssvc

import (
"fmt"
"os"

"github.com/wailsapp/wails/v3/pkg/application"
)

// SaveTextFile prompts with the OS-native save dialog (suggestedName
// pre-filled) and writes content to whatever path the user picks --
// the desktop-mode counterpart to a browser's own download prompt,
// which the Wails webview does not provide (an anchor-element download
// click is silently inert there). Returns the chosen path, or "" with
// a nil error when the user cancels. Server mode and any other run
// without a live windowed application (a headless `go test`, in
// particular) return a nil app from application.Get() -- the same
// no-op guard atlasservice_share.go's revealPath uses -- and this
// method reports that as an error rather than a silent no-op, since
// unlike an OS-reveal action a caller genuinely needs to know the
// save never happened. A real `-tags server` build additionally
// degrades through Wails3's own server-mode dialog stub, which
// already returns an equivalent "not available" error.
func (s *SettingsService) SaveTextFile(suggestedName, content string) (string, error) {
app := application.Get()
if app == nil {
return "", fmt.Errorf("native file save is not available in this mode")
}
path, err := app.Dialog.SaveFile().SetFilename(suggestedName).PromptForSingleSelection()
if err != nil {
return "", fmt.Errorf("native file save is not available in this mode: %w", err)
}
if path == "" {
return "", nil
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return "", fmt.Errorf("write file: %w", err)
}
return path, nil
}
31 changes: 31 additions & 0 deletions internal/services/settingssvc/settingsservice_savefile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package settingssvc

import (
"log/slog"
"testing"

"github.com/alicoding/mill/internal/services/compositionsvc"
"github.com/alicoding/mill/internal/services/servicetest"
"github.com/alicoding/mill/internal/services/triggersvc"
)

// TestSaveTextFile_NoLiveApplicationErrors: a headless `go test` run
// never calls application.New, so application.Get() is nil -- the same
// condition revealPath (atlasservice_share.go) treats as a silent
// no-op. SaveTextFile must instead report a real error rather than
// panic on the nil app, since a caller cannot tell "cancelled" apart
// from "never ran" without one.
func TestSaveTextFile_NoLiveApplicationErrors(t *testing.T) {
store := servicetest.NewFakeStore()
comp := compositionsvc.NewCompositionService(store)
trig := triggersvc.NewTriggerService(comp, slog.Default(), store)
set := NewSettingsService(store, trig, false)

path, err := set.SaveTextFile("export.json", `{"a":1}`)
if err == nil {
t.Fatal("SaveTextFile() with no live application: want an error, got nil")
}
if path != "" {
t.Errorf("SaveTextFile() path = %q, want empty on error", path)
}
}
8 changes: 7 additions & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ pre-commit:
run: go vet . ./internal/...
- name: golangci-lint
glob: "*.go"
run: golangci-lint run . ./internal/...
# --build-tags=server matches ci.yml's own golangci-lint-action
# args exactly -- without it this job lints the DEFAULT (desktop)
# tag set, a strictly larger file set than CI's job ever compiles
# (`!server`-tagged files CI's own -tags=server run never sees),
# so a local pass here was checking something CI doesn't and a
# local fail could block a commit CI's real gate would accept.
run: golangci-lint run . ./internal/... --build-tags=server
- name: go-test
glob: "*.go"
# Root package (`.`) included alongside ./internal/... -- the
Expand Down
Loading