From fa2cd3bb6626c458533852ced6e28fcf8110cb81 Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Tue, 21 Apr 2026 16:34:33 +0200 Subject: [PATCH 1/8] Update todo & add changelog --- CHANGELOG.md | 2 ++ todo.md | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..feeb06c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# 0.1.0 & 0.2.0 +- Initial release \ No newline at end of file diff --git a/todo.md b/todo.md index 5375844..ed832ca 100644 --- a/todo.md +++ b/todo.md @@ -1,3 +1,31 @@ -- Ci / CD + Versionierung und Bereitstellung der DAW Templates -- Gucken ob man FPC dynamisch konfigurieren kann -- \ No newline at end of file +# Better Workspaces (Planned Release: 0.3.0) +- Last opened workspace +- ✅ Search Workspace List +- Archive Workspaces to ignore them in the list and show them in a separate list of archived workspaces +- Export Workspaces as zip files that can be shared with other people. This zip file would include the workspace settings, the DAW template with the hitsound structure, and the generated diff export. + +# UX Improvements (Planned Release: 0.3.0) +- Better Feedback for successful actions (e.g. "Hitsound diff generated successfully!" notification after generating the diff) + +# Better Section Management (Planned Release: 0.4.0) +- User is able to Rename Sections for better organization (e.g. "Intro", "Verse", "Chorus", etc.) +- Enable or Disable sections to easily test different versions of the hitsound structure in-game without having to delete or move notes around in the DAW + +# Settings-Section (Planned Release: 0.5.0) +- Add a helper to install the FL Studio Script via osu!daws +- Change Workspace Folder location and migration setting to copy existing workspaces to the new location instead of just changing the location for new workspaces +- Export Settings: + - Default hitobject position optional + - Default generated diff name suffix (e.g. HS, Hitsounds, osu!daw's HS) + - Default gamemode for HS diff: osu!standard or Catch +- Automatically open last workspace on app start option + +# Better Exports (Planned Release: 1.0.0) +- New Button to directly open the generated diff in osu! Editor after generation +- Versionate Hitsound Diff exports v1, v2, ... +- Statistic after generation: number of hitsounds, number of custom sample sets, etc. +- Better Volume Distribution Option + - One of the problems is, that volume percentages can differ ~2-3% because in FL you have to klick the exact same pixel which can sometimes be hard. + - A better option would be to have a "Volume Step Size" setting in osu!daws which defines the step size for volume changes. + - For example, if you set it to 5%, then any velocity that falls within a 5% range would be rounded to the nearest step. So if you have a velocity that corresponds to 72%, and your step size is 5%, it would round to either 70% or 75% depending on which one is closer. + - This way, you can ensure more consistent volume levels without having to worry about pixel-perfect clicks in FL Studio. From b711ea34cd640d31da403a18791aae900973523b Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Tue, 21 Apr 2026 16:35:08 +0200 Subject: [PATCH 2/8] Enhance template provider to make it more open for other templates --- app/internal/workspace/templates.go | 43 ++------ app/internal/workspace/templates_flstudio.go | 100 ++++------------- .../workspace/templates_flstudio_test.go | 16 ++- app/internal/workspace/templates_support.go | 104 ++++++++++++++++++ 4 files changed, 145 insertions(+), 118 deletions(-) create mode 100644 app/internal/workspace/templates_support.go diff --git a/app/internal/workspace/templates.go b/app/internal/workspace/templates.go index 9bd22a7..e78c955 100644 --- a/app/internal/workspace/templates.go +++ b/app/internal/workspace/templates.go @@ -23,39 +23,27 @@ func (t TemplateDescriptor) AsRef() TemplateRef { return TemplateRef{DAW: t.DAW, ID: t.ID, Version: t.Version} } -// TemplateProvider is the extension point for a DAW/template. A -// provider knows its own metadata (via Descriptor) and performs the -// DAW-specific initialization of a freshly scaffolded workspace (via -// Initialize). Initialize is called after CreateWorkspace has made the -// directory layout, so / already exists and is empty -// on first run. -// -// Implementations should be pure w.r.t. Descriptor() — same values on -// every call — and idempotent w.r.t. Initialize where possible (safe -// to re-run on an existing workspace). type TemplateProvider interface { - // Descriptor returns the catalog entry describing this template. Descriptor() TemplateDescriptor - // Initialize performs DAW-specific setup inside the workspace. The - // supplied Paths are already scaffolded. Initialize(paths Paths) error } +type TemplateMigrator interface { + Migrate(paths Paths, fromVersion string) error +} + +var registeredProviders []TemplateProvider + +func RegisterProvider(p TemplateProvider) { + registeredProviders = append(registeredProviders, p) +} + // TemplateCatalog is a read-only registry of available templates. -// Internally it stores TemplateProviders; callers can retrieve just -// the descriptor metadata via List/ByID/Default, and look up the live -// provider via ProviderByID / DefaultProvider. -// -// The catalog is intentionally tiny today: only the FL Studio hitsound -// template is shipped. The shape is ready for more DAWs to slot in -// later without changing the UI layer. type TemplateCatalog struct { providers []TemplateProvider } // NewTemplateCatalog builds a catalog from the given providers. It -// panics on duplicate template IDs, which is a programmer error. Tests -// use this constructor to inject spy providers. func NewTemplateCatalog(providers ...TemplateProvider) *TemplateCatalog { seen := map[string]struct{}{} for _, p := range providers { @@ -68,11 +56,9 @@ func NewTemplateCatalog(providers ...TemplateProvider) *TemplateCatalog { return &TemplateCatalog{providers: providers} } -// NewDefaultCatalog returns the catalog shipped with the current build. +// NewDefaultCatalog returns the catalog shipped with the current func NewDefaultCatalog() *TemplateCatalog { - return NewTemplateCatalog( - FLStudioProvider{}, - ) + return NewTemplateCatalog(registeredProviders...) } // List returns all catalog entries in a stable order. @@ -93,14 +79,11 @@ func (c *TemplateCatalog) ByID(id string) (TemplateDescriptor, bool) { } // Default returns the first catalog entry. Used as the default UI -// selection. Panics only if the catalog is empty, which is a -// programmer error for a shipped build. func (c *TemplateCatalog) Default() TemplateDescriptor { return c.DefaultProvider().Descriptor() } // ProviderByID looks up the live provider with the given catalog ID. -// Returns (nil, false) when the ID is unknown. func (c *TemplateCatalog) ProviderByID(id string) (TemplateProvider, bool) { for _, p := range c.providers { if p.Descriptor().ID == id { @@ -111,8 +94,6 @@ func (c *TemplateCatalog) ProviderByID(id string) (TemplateProvider, bool) { } // DefaultProvider returns the first registered provider. Panics when -// the catalog is empty, which is a programmer error for a shipped -// build. func (c *TemplateCatalog) DefaultProvider() TemplateProvider { if len(c.providers) == 0 { panic("workspace: TemplateCatalog is empty") diff --git a/app/internal/workspace/templates_flstudio.go b/app/internal/workspace/templates_flstudio.go index 23ce21c..3364808 100644 --- a/app/internal/workspace/templates_flstudio.go +++ b/app/internal/workspace/templates_flstudio.go @@ -2,19 +2,13 @@ package workspace import ( "embed" - "encoding/json" - "io/fs" "os" - "path/filepath" - "strings" - "time" ) // FLStudioTemplateID is the catalog ID of the FL Studio hitsound template. const FLStudioTemplateID = "osu!daw hitsound template" // FLStudioTemplateVersion tracks the on-disk layout of the FL Studio -// template. Bump alongside changes to the shipped .flp / sample pack. const FLStudioTemplateVersion = "1" const ( @@ -33,8 +27,13 @@ const ( //go:embed flstudio_assets var flStudioAssets embed.FS -// FLStudioProvider copies the embedded FL Studio template into the -// workspace's template/ directory and writes a template_info.json marker. +// FLStudioExtra is the FL-specific payload stored inside +type FLStudioExtra struct { + RootDir string `json:"root_dir"` + EntryFile string `json:"entry_file"` +} + +// FLStudioProvider materializes the embedded FL Studio template into a type FLStudioProvider struct{} func (FLStudioProvider) Descriptor() TemplateDescriptor { @@ -47,86 +46,25 @@ func (FLStudioProvider) Descriptor() TemplateDescriptor { } } -const templateInfoFileName = "template_info.json" - -// TemplateInfo is the JSON marker written inside a workspace's template/ -// folder after a provider has initialized it. Future migrations inspect -// this file to decide whether to re-run or upgrade assets. -type TemplateInfo struct { - TemplateID string `json:"template_id"` - DAW DAWType `json:"daw"` - Version string `json:"version"` - - RootDir string `json:"root_dir,omitempty"` - EntryFile string `json:"entry_file,omitempty"` - - InitializedAt time.Time `json:"initialized_at"` -} - -// nowTemplate is the clock used by Initialize. Tests may override it. -var nowTemplate = func() time.Time { return time.Now().UTC() } - -// Initialize materializes the embedded FL Studio template inside -// paths.Template and writes the marker file. Idempotent: re-running -// refreshes asset contents and the marker and leaves unrelated files -// (user work under different names) untouched. +// Initialize copies the embedded asset tree into paths.Template and +// writes the generic template marker with an FLStudioExtra payload. func (p FLStudioProvider) Initialize(paths Paths) error { if err := os.MkdirAll(paths.Template, 0o755); err != nil { return &Error{Code: ErrIO, Message: "cannot create template directory: " + paths.Template, Cause: err} } - - if err := copyEmbedTree(flStudioAssets, flStudioAssetsRoot, paths.Template); err != nil { + if err := CopyEmbedTree(flStudioAssets, flStudioAssetsRoot, paths.Template); err != nil { return &Error{Code: ErrIO, Message: "cannot copy FL Studio template assets", Cause: err} } - - desc := p.Descriptor() - info := TemplateInfo{ - TemplateID: desc.ID, - DAW: desc.DAW, - Version: desc.Version, - RootDir: FLStudioRootDir, - EntryFile: FLStudioEntryFile, - InitializedAt: nowTemplate(), - } - data, err := json.MarshalIndent(info, "", " ") - if err != nil { - return &Error{Code: ErrIO, - Message: "cannot marshal template info", Cause: err} - } - target := filepath.Join(paths.Template, templateInfoFileName) - if err := os.WriteFile(target, data, 0o644); err != nil { - return &Error{Code: ErrIO, - Message: "cannot write template marker: " + target, Cause: err} - } - return nil -} - -// copyEmbedTree walks src inside efs and mirrors its contents into dst. -// Existing files are overwritten. embed.FS uses forward slashes and they -// are translated to the host separator. -func copyEmbedTree(efs embed.FS, src, dst string) error { - return fs.WalkDir(efs, src, func(p string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel := strings.TrimPrefix(p, src) - rel = strings.TrimPrefix(rel, "/") - if rel == "" { - return nil - } - target := filepath.Join(dst, filepath.FromSlash(rel)) - if d.IsDir() { - return os.MkdirAll(target, 0o755) - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - data, err := efs.ReadFile(p) - if err != nil { - return err - } - return os.WriteFile(target, data, 0o644) + return WriteTemplateMarker(paths, p.Descriptor(), FLStudioExtra{ + RootDir: FLStudioRootDir, + EntryFile: FLStudioEntryFile, }) } + +// Self-register the provider so NewDefaultCatalog picks it up without +// edits to templates.go. Adding a new DAW is therefore a single-file +// change: drop a templates_.go alongside this one, implement +// TemplateProvider, call RegisterProvider from init(). +func init() { RegisterProvider(FLStudioProvider{}) } diff --git a/app/internal/workspace/templates_flstudio_test.go b/app/internal/workspace/templates_flstudio_test.go index 453f463..47205cb 100644 --- a/app/internal/workspace/templates_flstudio_test.go +++ b/app/internal/workspace/templates_flstudio_test.go @@ -81,14 +81,18 @@ func TestFLStudioInit_MarkerIncludesLayoutInfo(t *testing.T) { if err := json.Unmarshal(data, &info); err != nil { t.Fatal(err) } - if info.RootDir != FLStudioRootDir { - t.Errorf("RootDir = %q, want %q", info.RootDir, FLStudioRootDir) + var extra FLStudioExtra + if err := info.DecodeExtra(&extra); err != nil { + t.Fatalf("decode extra: %v", err) } - if info.EntryFile != FLStudioEntryFile { - t.Errorf("EntryFile = %q, want %q", info.EntryFile, FLStudioEntryFile) + if extra.RootDir != FLStudioRootDir { + t.Errorf("RootDir = %q, want %q", extra.RootDir, FLStudioRootDir) } - if !strings.HasSuffix(info.EntryFile, ".flp") { - t.Errorf("EntryFile should end in .flp, got %q", info.EntryFile) + if extra.EntryFile != FLStudioEntryFile { + t.Errorf("EntryFile = %q, want %q", extra.EntryFile, FLStudioEntryFile) + } + if !strings.HasSuffix(extra.EntryFile, ".flp") { + t.Errorf("EntryFile should end in .flp, got %q", extra.EntryFile) } } diff --git a/app/internal/workspace/templates_support.go b/app/internal/workspace/templates_support.go new file mode 100644 index 0000000..aefac17 --- /dev/null +++ b/app/internal/workspace/templates_support.go @@ -0,0 +1,104 @@ +package workspace + +import ( + "embed" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +const templateInfoFileName = "template_info.json" + +type TemplateInfo struct { + TemplateID string `json:"template_id"` + DAW DAWType `json:"daw"` + Version string `json:"version"` + InitializedAt time.Time `json:"initialized_at"` + + // Extra is the provider-defined payload. Unmarshal into the + // provider's companion struct (e.g. FLStudioExtra) to read it. + Extra json.RawMessage `json:"extra,omitempty"` +} + +func (t TemplateInfo) DecodeExtra(v any) error { + if len(t.Extra) == 0 { + return nil + } + return json.Unmarshal(t.Extra, v) +} + +var nowTemplate = func() time.Time { return time.Now().UTC() } + +func WriteTemplateMarker(paths Paths, desc TemplateDescriptor, extra any) error { + var raw json.RawMessage + if extra != nil { + b, err := json.Marshal(extra) + if err != nil { + return &Error{Code: ErrIO, + Message: "cannot marshal template extra payload", Cause: err} + } + raw = b + } + info := TemplateInfo{ + TemplateID: desc.ID, + DAW: desc.DAW, + Version: desc.Version, + InitializedAt: nowTemplate(), + Extra: raw, + } + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return &Error{Code: ErrIO, + Message: "cannot marshal template info", Cause: err} + } + target := filepath.Join(paths.Template, templateInfoFileName) + if err := os.WriteFile(target, data, 0o644); err != nil { + return &Error{Code: ErrIO, + Message: "cannot write template marker: " + target, Cause: err} + } + return nil +} + +func ReadTemplateMarker(paths Paths) (TemplateInfo, bool, error) { + target := filepath.Join(paths.Template, templateInfoFileName) + data, err := os.ReadFile(target) + if err != nil { + if os.IsNotExist(err) { + return TemplateInfo{}, false, nil + } + return TemplateInfo{}, false, err + } + var info TemplateInfo + if err := json.Unmarshal(data, &info); err != nil { + return TemplateInfo{}, true, err + } + return info, true, nil +} + +func CopyEmbedTree(efs embed.FS, src, dst string) error { + return fs.WalkDir(efs, src, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel := strings.TrimPrefix(p, src) + rel = strings.TrimPrefix(rel, "/") + if rel == "" { + return nil + } + target := filepath.Join(dst, filepath.FromSlash(rel)) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + data, err := efs.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) +} From 996657e655d82276c7627798d021051e54083996 Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Tue, 21 Apr 2026 16:41:19 +0200 Subject: [PATCH 3/8] Add search functionality for workspaces --- app/internal/ui/start.go | 29 ++++++++++-- app/internal/ui/startviewmodel.go | 21 +++++++++ app/internal/ui/startviewmodel_test.go | 63 ++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/app/internal/ui/start.go b/app/internal/ui/start.go index a154553..5e737f6 100644 --- a/app/internal/ui/start.go +++ b/app/internal/ui/start.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "path/filepath" + "strings" "time" "osu-daws-app/internal/detect" @@ -43,11 +44,19 @@ func BuildStartScreen(w fyne.Window, svm *StartViewModel, cb StartScreenCallback rerender() }) + searchEntry := widget.NewEntry() + searchEntry.SetPlaceHolder("Search workspaces by name…") + searchEntry.SetText(svm.SearchQuery) + searchEntry.OnChanged = func(s string) { + svm.SearchQuery = s + rerender() + } + header := container.NewBorder( nil, nil, sectionTitle("Workspaces"), container.NewHBox(refreshBtn, createBtn), - nil, + searchEntry, ) rerender = func() { @@ -82,12 +91,22 @@ func buildWorkspaceList( svm *StartViewModel, cb StartScreenCallbacks, ) fyne.CanvasObject { - items := svm.Workspaces() + items := svm.FilteredWorkspaces() + total := len(svm.Workspaces()) + filtering := strings.TrimSpace(svm.SearchQuery) != "" if len(items) == 0 { - empty := widget.NewLabel( - "No workspaces yet.\n\nClick “Create New Workspace” to start a new project.", - ) + var msg string + switch { + case filtering && total > 0: + msg = fmt.Sprintf( + "No workspaces match %q.\n\nTry a different search term or clear the search.", + svm.SearchQuery, + ) + default: + msg = "No workspaces yet.\n\nClick “Create New Workspace” to start a new project." + } + empty := widget.NewLabel(msg) empty.Wrapping = fyne.TextWrapWord empty.Alignment = fyne.TextAlignCenter diff --git a/app/internal/ui/startviewmodel.go b/app/internal/ui/startviewmodel.go index 3bf8c5b..84fe4f5 100644 --- a/app/internal/ui/startviewmodel.go +++ b/app/internal/ui/startviewmodel.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "strings" "time" "osu-daws-app/internal/workspace" @@ -14,6 +15,8 @@ type StartViewModel struct { LastReferencePath string Catalog *workspace.TemplateCatalog + SearchQuery string + create *workspace.CreateService now func() time.Time @@ -65,6 +68,24 @@ func (svm *StartViewModel) Workspaces() []workspace.Summary { return svm.lastList.Workspaces } +// FilteredWorkspaces returns Workspaces() filtered by SearchQuery as a +// case-insensitive substring match against Summary.Name. An empty query +// returns the full list unchanged. +func (svm *StartViewModel) FilteredWorkspaces() []workspace.Summary { + all := svm.Workspaces() + q := strings.TrimSpace(strings.ToLower(svm.SearchQuery)) + if q == "" { + return all + } + out := make([]workspace.Summary, 0, len(all)) + for _, s := range all { + if strings.Contains(strings.ToLower(s.Name), q) { + out = append(out, s) + } + } + return out +} + // Skipped returns directories that looked like workspaces but could not be loaded. func (svm *StartViewModel) Skipped() []workspace.SkippedEntry { if svm.lastList == nil { diff --git a/app/internal/ui/startviewmodel_test.go b/app/internal/ui/startviewmodel_test.go index 83cff73..0e68940 100644 --- a/app/internal/ui/startviewmodel_test.go +++ b/app/internal/ui/startviewmodel_test.go @@ -224,3 +224,66 @@ func TestStartVM_CreateWorkspace_FieldErrorsReturned(t *testing.T) { t.Errorf("expected FieldName error, got %v", fe) } } + +func TestStartVM_FilteredWorkspaces(t *testing.T) { + root := t.TempDir() + svm := NewStartViewModel(root) + + for _, n := range []string{"Alpha Map", "Beta Song", "Gamma"} { + if _, err := createTestWorkspace(svm, n); err != nil { + t.Fatal(err) + } + } + if err := svm.Refresh(); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + query string + want []string // subset of names expected (order-insensitive) + }{ + {"empty returns all", "", []string{"Alpha Map", "Beta Song", "Gamma"}}, + {"whitespace returns all", " ", []string{"Alpha Map", "Beta Song", "Gamma"}}, + {"substring match", "song", []string{"Beta Song"}}, + {"case insensitive", "GAMMA", []string{"Gamma"}}, + {"no match", "zzz", nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + svm.SearchQuery = c.query + got := svm.FilteredWorkspaces() + if len(got) != len(c.want) { + t.Fatalf("len=%d want=%d (%v)", len(got), len(c.want), namesOf(got)) + } + for _, wantName := range c.want { + found := false + for _, g := range got { + if g.Name == wantName { + found = true + break + } + } + if !found { + t.Errorf("missing %q in %v", wantName, namesOf(got)) + } + } + }) + } +} + +func TestStartVM_FilteredWorkspaces_BeforeRefresh(t *testing.T) { + svm := NewStartViewModel(t.TempDir()) + svm.SearchQuery = "anything" + if got := svm.FilteredWorkspaces(); len(got) != 0 { + t.Errorf("expected empty result before Refresh, got %d", len(got)) + } +} + +func namesOf(ws []workspace.Summary) []string { + out := make([]string, len(ws)) + for i, w := range ws { + out[i] = w.Name + } + return out +} From 89c45599d78043a5bab6620ec5140e1bcfe3ec0a Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Tue, 21 Apr 2026 17:14:49 +0200 Subject: [PATCH 4/8] Add workspace export and import functionality --- app/internal/ui/start.go | 121 ++++++++- app/internal/ui/startviewmodel.go | 17 ++ app/internal/ui/startviewmodel_test.go | 41 +++ app/internal/workspace/archive.go | 337 +++++++++++++++++++++++++ app/internal/workspace/archive_test.go | 220 ++++++++++++++++ 5 files changed, 723 insertions(+), 13 deletions(-) create mode 100644 app/internal/workspace/archive.go create mode 100644 app/internal/workspace/archive_test.go diff --git a/app/internal/ui/start.go b/app/internal/ui/start.go index 5e737f6..38087e8 100644 --- a/app/internal/ui/start.go +++ b/app/internal/ui/start.go @@ -44,6 +44,10 @@ func BuildStartScreen(w fyne.Window, svm *StartViewModel, cb StartScreenCallback rerender() }) + importBtn := widget.NewButton("Import…", func() { + showImportWorkspaceDialog(w, svm, cb, rerender) + }) + searchEntry := widget.NewEntry() searchEntry.SetPlaceHolder("Search workspaces by name…") searchEntry.SetText(svm.SearchQuery) @@ -55,7 +59,7 @@ func BuildStartScreen(w fyne.Window, svm *StartViewModel, cb StartScreenCallback header := container.NewBorder( nil, nil, sectionTitle("Workspaces"), - container.NewHBox(refreshBtn, createBtn), + container.NewHBox(refreshBtn, importBtn, createBtn), searchEntry, ) @@ -120,16 +124,21 @@ func buildWorkspaceList( rows := container.NewVBox() for i := range items { item := items[i] - rows.Add(workspaceRow(item, func() { - ws, err := workspace.LoadWorkspace(item.Root) - if err != nil { - dialog.ShowError(err, w) - return - } - if cb.OnOpen != nil { - cb.OnOpen(ws) - } - })) + rows.Add(workspaceRow(item, + func() { + ws, err := workspace.LoadWorkspace(item.Root) + if err != nil { + dialog.ShowError(err, w) + return + } + if cb.OnOpen != nil { + cb.OnOpen(ws) + } + }, + func() { + showExportWorkspaceDialog(w, svm, item) + }, + )) rows.Add(vSpace(4)) } @@ -140,13 +149,15 @@ func buildWorkspaceList( return body } -func workspaceRow(s workspace.Summary, onOpen func()) fyne.CanvasObject { +func workspaceRow(s workspace.Summary, onOpen, onExport func()) fyne.CanvasObject { name := widget.NewLabel(displayName(s.Name)) name.TextStyle = fyne.TextStyle{Bold: true} openBtn := widget.NewButton("Open", onOpen) openBtn.Importance = widget.HighImportance + exportBtn := widget.NewButton("Export…", onExport) + meta := widget.NewLabel(formatMeta(s)) meta.TextStyle = fyne.TextStyle{Italic: true} meta.Wrapping = fyne.TextWrapWord @@ -155,8 +166,10 @@ func workspaceRow(s workspace.Summary, onOpen func()) fyne.CanvasObject { path.TextStyle = fyne.TextStyle{Italic: true} path.Wrapping = fyne.TextWrapWord + actions := container.NewHBox(exportBtn, openBtn) + body := container.NewVBox( - container.NewBorder(nil, nil, nil, openBtn, name), + container.NewBorder(nil, nil, nil, actions, name), path, meta, ) @@ -377,3 +390,85 @@ func formatCreateError(err error) string { } return err.Error() } + +// showExportWorkspaceDialog prompts the user for a destination .zip +// and exports the selected workspace there. Uses Fyne's file save +// dialog; the view model owns the actual zip write. +func showExportWorkspaceDialog( + w fyne.Window, + svm *StartViewModel, + item workspace.Summary, +) { + fd := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) { + if err != nil { + dialog.ShowError(err, w) + return + } + if wc == nil { + return // user cancelled + } + // We need the target path; Fyne's URIWriteCloser exposes it + // via URI().Path(). Close the handle first so we own the file. + target := wc.URI().Path() + _ = wc.Close() + + if !strings.EqualFold(filepath.Ext(target), workspace.ArchiveFileExtension) { + target += workspace.ArchiveFileExtension + } + if err := svm.ExportWorkspaceToZip(item, target); err != nil { + dialog.ShowError(fmt.Errorf("export failed: %w", err), w) + return + } + dialog.ShowInformation( + "Workspace exported", + "Saved to:\n"+target, + w, + ) + }, w) + + // Suggest a friendly filename derived from the workspace name. + fd.SetFileName(workspace.SuggestExportFileName(&workspace.Workspace{ + Project: &workspace.ProjectFile{Name: item.Name}, + })) + fd.SetFilter(storage.NewExtensionFileFilter([]string{workspace.ArchiveFileExtension})) + fd.Show() +} + +// showImportWorkspaceDialog prompts the user for a .zip archive and +// imports it under a fresh workspace ID. On success the list refreshes; +// the callback opens the newly imported workspace if OnCreate is wired. +func showImportWorkspaceDialog( + w fyne.Window, + svm *StartViewModel, + cb StartScreenCallbacks, + rerender func(), +) { + fd := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) { + if err != nil { + dialog.ShowError(err, w) + return + } + if rc == nil { + return // user cancelled + } + src := rc.URI().Path() + _ = rc.Close() + + ws, err := svm.ImportWorkspaceFromZip(src) + if err != nil { + dialog.ShowError(fmt.Errorf("import failed: %w", err), w) + return + } + rerender() + dialog.ShowInformation( + "Workspace imported", + fmt.Sprintf("%q was imported successfully.", ws.Project.Name), + w, + ) + if cb.OnCreate != nil { + cb.OnCreate(ws) + } + }, w) + fd.SetFilter(storage.NewExtensionFileFilter([]string{workspace.ArchiveFileExtension})) + fd.Show() +} diff --git a/app/internal/ui/startviewmodel.go b/app/internal/ui/startviewmodel.go index 84fe4f5..45d99dc 100644 --- a/app/internal/ui/startviewmodel.go +++ b/app/internal/ui/startviewmodel.go @@ -103,3 +103,20 @@ func (svm *StartViewModel) CreateWorkspace(req workspace.CreateRequest) (*worksp _ = svm.Refresh() return ws, nil } + +func (svm *StartViewModel) ImportWorkspaceFromZip(zipPath string) (*workspace.Workspace, error) { + ws, err := workspace.ImportWorkspace(svm.ProjectsRoot, zipPath) + if err != nil { + return nil, err + } + _ = svm.Refresh() + return ws, nil +} + +func (svm *StartViewModel) ExportWorkspaceToZip(summary workspace.Summary, destZip string) error { + ws, err := workspace.LoadWorkspace(summary.Root) + if err != nil { + return err + } + return workspace.ExportWorkspace(ws, destZip) +} diff --git a/app/internal/ui/startviewmodel_test.go b/app/internal/ui/startviewmodel_test.go index 0e68940..5547478 100644 --- a/app/internal/ui/startviewmodel_test.go +++ b/app/internal/ui/startviewmodel_test.go @@ -287,3 +287,44 @@ func namesOf(ws []workspace.Summary) []string { } return out } + +func TestStartVM_ExportImportRoundTrip(t *testing.T) { + srcRoot := t.TempDir() + dstRoot := t.TempDir() + zipPath := filepath.Join(t.TempDir(), "export.zip") + + srcSVM := NewStartViewModel(srcRoot) + if _, err := createTestWorkspace(srcSVM, "Round Trip"); err != nil { + t.Fatal(err) + } + if err := srcSVM.Refresh(); err != nil { + t.Fatal(err) + } + items := srcSVM.Workspaces() + if len(items) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(items)) + } + + if err := srcSVM.ExportWorkspaceToZip(items[0], zipPath); err != nil { + t.Fatalf("export: %v", err) + } + info, err := os.Stat(zipPath) + if err != nil || info.Size() == 0 { + t.Fatalf("zip file missing or empty: %v", err) + } + + // Import into a separate projects root. + dstSVM := NewStartViewModel(dstRoot) + imported, err := dstSVM.ImportWorkspaceFromZip(zipPath) + if err != nil { + t.Fatalf("import: %v", err) + } + if imported.Project.Name != "Round Trip" { + t.Errorf("imported name = %q", imported.Project.Name) + } + // View model should see the new workspace without an extra Refresh. + if len(dstSVM.Workspaces()) != 1 { + t.Errorf("expected 1 imported workspace after refresh, got %d", + len(dstSVM.Workspaces())) + } +} diff --git a/app/internal/workspace/archive.go b/app/internal/workspace/archive.go new file mode 100644 index 0000000..faf2ce4 --- /dev/null +++ b/app/internal/workspace/archive.go @@ -0,0 +1,337 @@ +package workspace + +import ( + "archive/zip" + "encoding/json" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +const ArchiveFileExtension = ".zip" + +func ExportWorkspace(ws *Workspace, destZip string) error { + if ws == nil || ws.Paths.Root == "" { + return &Error{Code: ErrProjectFileIncomplete, + Message: "workspace has no root path"} + } + if strings.TrimSpace(destZip) == "" { + return &Error{Code: ErrIO, Message: "destination zip path is empty"} + } + if err := os.MkdirAll(filepath.Dir(destZip), 0o755); err != nil { + return &Error{Code: ErrIO, + Message: "cannot create destination directory", Cause: err} + } + + tmp := destZip + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return &Error{Code: ErrIO, Message: "cannot create zip: " + tmp, Cause: err} + } + + if writeErr := ExportWorkspaceTo(ws, f); writeErr != nil { + _ = f.Close() + _ = os.Remove(tmp) + return writeErr + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return &Error{Code: ErrIO, Message: "cannot close zip: " + tmp, Cause: err} + } + if err := os.Rename(tmp, destZip); err != nil { + _ = os.Remove(tmp) + return &Error{Code: ErrIO, + Message: "cannot rename zip into place", Cause: err} + } + return nil +} + +func ExportWorkspaceTo(ws *Workspace, w io.Writer) error { + if ws == nil || ws.Paths.Root == "" { + return &Error{Code: ErrProjectFileIncomplete, + Message: "workspace has no root path"} + } + if info, err := os.Stat(ws.Paths.Root); err != nil || !info.IsDir() { + return &Error{Code: ErrIO, + Message: "workspace root is not a directory: " + ws.Paths.Root, + Cause: err} + } + zw := zip.NewWriter(w) + + walkErr := filepath.WalkDir(ws.Paths.Root, func(p string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + if p == ws.Paths.Root { + return nil + } + rel, err := filepath.Rel(ws.Paths.Root, p) + if err != nil { + return err + } + // Skip the transient project.odaw.tmp we use during atomic writes. + base := filepath.Base(p) + if base == ProjectFileName+".tmp" { + return nil + } + name := filepath.ToSlash(rel) + + if d.IsDir() { + _, err := zw.Create(name + "/") + return err + } + srcInfo, err := d.Info() + if err != nil { + return err + } + header, err := zip.FileInfoHeader(srcInfo) + if err != nil { + return err + } + header.Name = name + header.Method = zip.Deflate + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + src, err := os.Open(p) + if err != nil { + return err + } + _, copyErr := io.Copy(writer, src) + closeErr := src.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) + if walkErr != nil { + _ = zw.Close() + return &Error{Code: ErrIO, + Message: "cannot pack workspace into zip", Cause: walkErr} + } + if err := zw.Close(); err != nil { + return &Error{Code: ErrIO, Message: "cannot finalize zip", Cause: err} + } + return nil +} + +func ImportWorkspace(projectsRoot, srcZip string) (*Workspace, error) { + if strings.TrimSpace(srcZip) == "" { + return nil, &Error{Code: ErrIO, Message: "source zip path is empty"} + } + f, err := os.Open(srcZip) + if err != nil { + return nil, &Error{Code: ErrIO, + Message: "cannot open zip: " + srcZip, Cause: err} + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, &Error{Code: ErrIO, + Message: "cannot stat zip: " + srcZip, Cause: err} + } + return ImportWorkspaceFrom(projectsRoot, f, info.Size()) +} + +func ImportWorkspaceFrom(projectsRoot string, r io.ReaderAt, size int64) (*Workspace, error) { + if strings.TrimSpace(projectsRoot) == "" { + return nil, &Error{Code: ErrIO, Message: "projects root is empty"} + } + zr, err := zip.NewReader(r, size) + if err != nil { + return nil, &Error{Code: ErrIO, + Message: "cannot read zip archive", Cause: err} + } + + projectEntry, prefix, err := locateProjectFileInZip(zr) + if err != nil { + return nil, err + } + pfBytes, err := readZipFile(projectEntry) + if err != nil { + return nil, &Error{Code: ErrIO, + Message: "cannot read " + ProjectFileName + " from zip", Cause: err} + } + pf, err := unmarshalImportedProjectFile(pfBytes) + if err != nil { + return nil, err + } + + // Allocate a brand-new ID so imports never clobber existing + // workspaces, even if two people traded the same archive. + pf.ID = NewID(pf.Name) + pf.UpdatedAt = time.Now().UTC() + if pf.CreatedAt.IsZero() { + pf.CreatedAt = pf.UpdatedAt + } + + dstRoot := WorkspaceRoot(projectsRoot, pf.ID) + if err := os.MkdirAll(dstRoot, 0o755); err != nil { + return nil, &Error{Code: ErrIO, + Message: "cannot create workspace directory: " + dstRoot, Cause: err} + } + absDstRoot, err := filepath.Abs(dstRoot) + if err != nil { + return nil, &Error{Code: ErrIO, + Message: "cannot resolve workspace directory", Cause: err} + } + + for _, f := range zr.File { + rel, skip := stripPrefix(f.Name, prefix) + if skip { + continue + } + // project.odaw is written separately below with the new ID. + if rel == ProjectFileName { + continue + } + if err := extractZipEntry(f, absDstRoot, rel); err != nil { + // Best-effort cleanup so a failed import doesn't leave a + // half-extracted workspace lying around. + _ = os.RemoveAll(dstRoot) + return nil, err + } + } + + paths := PathsFromRoot(dstRoot) + if err := SaveProjectFile(paths, pf); err != nil { + _ = os.RemoveAll(dstRoot) + return nil, err + } + return &Workspace{Paths: paths, Project: pf}, nil +} + +func SuggestExportFileName(ws *Workspace) string { + name := "workspace" + if ws != nil && ws.Project != nil { + if s := Slug(ws.Project.Name); s != "" { + name = s + } + } + return name + ArchiveFileExtension +} +func locateProjectFileInZip(zr *zip.Reader) (*zip.File, string, error) { + var best *zip.File + var bestPrefix string + for _, f := range zr.File { + if strings.HasSuffix(f.Name, "/") { + continue + } + if path.Base(f.Name) != ProjectFileName { + continue + } + prefix := strings.TrimSuffix(f.Name, ProjectFileName) + if best == nil || len(prefix) < len(bestPrefix) { + best = f + bestPrefix = prefix + } + } + if best == nil { + return nil, "", &Error{Code: ErrProjectFileMissing, + Message: "zip does not contain " + ProjectFileName} + } + return best, bestPrefix, nil +} + +func stripPrefix(name, prefix string) (rel string, skip bool) { + if prefix == "" { + return name, name == "" + } + if !strings.HasPrefix(name, prefix) { + return "", true + } + rel = strings.TrimPrefix(name, prefix) + if rel == "" { + return "", true + } + return rel, false +} + +func readZipFile(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) +} + +func unmarshalImportedProjectFile(data []byte) (*ProjectFile, error) { + var pf ProjectFile + if err := json.Unmarshal(data, &pf); err != nil { + return nil, &Error{Code: ErrProjectFileInvalid, + Message: "project file in zip is not valid JSON", Cause: err} + } + if pf.Version <= 0 || pf.Version > CurrentProjectFileVersion { + return nil, &Error{Code: ErrProjectFileVersion, + Message: "unsupported project file version in zip"} + } + if strings.TrimSpace(pf.Name) == "" { + return nil, &Error{Code: ErrProjectFileIncomplete, + Message: "project file in zip is missing required field: name"} + } + if pf.Segments == nil { + pf.Segments = []SegmentInput{} + } + return &pf, nil +} + +func extractZipEntry(f *zip.File, absDstRoot, rel string) error { + cleaned := path.Clean(rel) + if cleaned == "." || cleaned == ".." || + strings.HasPrefix(cleaned, "../") || + strings.HasPrefix(cleaned, "/") || + strings.Contains(cleaned, "/../") || + strings.HasSuffix(cleaned, "/..") { + return &Error{Code: ErrIO, + Message: "refusing unsafe zip entry: " + f.Name} + } + target := filepath.Join(absDstRoot, filepath.FromSlash(cleaned)) + absTarget, err := filepath.Abs(target) + if err != nil { + return &Error{Code: ErrIO, + Message: "cannot resolve zip entry path", Cause: err} + } + if absTarget != absDstRoot && + !strings.HasPrefix(absTarget, absDstRoot+string(filepath.Separator)) { + return &Error{Code: ErrIO, + Message: "zip entry escapes destination: " + f.Name} + } + + if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/") { + return os.MkdirAll(absTarget, 0o755) + } + if err := os.MkdirAll(filepath.Dir(absTarget), 0o755); err != nil { + return &Error{Code: ErrIO, + Message: "cannot create directory for zip entry", Cause: err} + } + rc, err := f.Open() + if err != nil { + return &Error{Code: ErrIO, + Message: "cannot open zip entry: " + f.Name, Cause: err} + } + defer rc.Close() + out, err := os.Create(absTarget) + if err != nil { + return &Error{Code: ErrIO, + Message: "cannot create file: " + absTarget, Cause: err} + } + _, copyErr := io.Copy(out, rc) + closeErr := out.Close() + switch { + case copyErr != nil: + return &Error{Code: ErrIO, + Message: "cannot write file: " + absTarget, Cause: copyErr} + case closeErr != nil: + return &Error{Code: ErrIO, + Message: "cannot close file: " + absTarget, Cause: closeErr} + } + return nil +} + +var _ = SuggestExportFileName(nil) diff --git a/app/internal/workspace/archive_test.go b/app/internal/workspace/archive_test.go new file mode 100644 index 0000000..bc424a1 --- /dev/null +++ b/app/internal/workspace/archive_test.go @@ -0,0 +1,220 @@ +package workspace + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "osu-daws-app/internal/domain" +) + +// archiveTestCatalog returns a catalog with the FL Studio provider (the +// default shipped one). Local helper for readability. +func archiveTestCatalog(t *testing.T) *TemplateCatalog { + t.Helper() + return NewDefaultCatalog() +} + +// makeWorkspaceOnDisk scaffolds a real workspace with the FL Studio +// template inside root and returns the loaded Workspace. +func makeWorkspaceOnDisk(t *testing.T, root, name string) *Workspace { + t.Helper() + s := NewCreateService(root, archiveTestCatalog(t)) + ws, err := s.Create(CreateRequest{ + Name: name, + Template: s.Catalog.Default(), + DefaultSampleset: domain.SamplesetSoft, + }) + if err != nil { + t.Fatalf("create workspace: %v", err) + } + return ws +} + +func TestExportImport_RoundTripPreservesTree(t *testing.T) { + srcRoot := t.TempDir() + dstRoot := t.TempDir() + ws := makeWorkspaceOnDisk(t, srcRoot, "My Song") + + // Seed a file into exports/ so we exercise that branch too. + exported := filepath.Join(ws.Paths.Exports, "mapping.osu") + if err := os.WriteFile(exported, []byte("exported content"), 0o644); err != nil { + t.Fatal(err) + } + + zipPath := filepath.Join(t.TempDir(), "out.zip") + if err := ExportWorkspace(ws, zipPath); err != nil { + t.Fatalf("export: %v", err) + } + + imported, err := ImportWorkspace(dstRoot, zipPath) + if err != nil { + t.Fatalf("import: %v", err) + } + if imported.Project.Name != ws.Project.Name { + t.Errorf("Name = %q, want %q", imported.Project.Name, ws.Project.Name) + } + if imported.Project.ID == ws.Project.ID { + t.Errorf("ID should be freshly minted on import, got same: %s", imported.Project.ID) + } + if !strings.HasPrefix(string(imported.Project.ID), "my-song-") { + t.Errorf("ID %q should start with slug prefix", imported.Project.ID) + } + // Check the exports/ file survived. + dstExport := filepath.Join(imported.Paths.Exports, "mapping.osu") + data, err := os.ReadFile(dstExport) + if err != nil { + t.Fatalf("imported exports file missing: %v", err) + } + if string(data) != "exported content" { + t.Errorf("exports content = %q, want %q", string(data), "exported content") + } + // Template tree survived: entry .flp present. + entry := filepath.Join(imported.Paths.Template, filepath.FromSlash(FLStudioEntryFile)) + if _, err := os.Stat(entry); err != nil { + t.Errorf("template entry missing after import: %v", err) + } +} + +func TestExport_NilWorkspace(t *testing.T) { + err := ExportWorkspace(nil, filepath.Join(t.TempDir(), "x.zip")) + if err == nil { + t.Fatal("expected error for nil workspace") + } +} + +func TestImport_MissingProjectFile(t *testing.T) { + // Zip with only an unrelated file and no project.odaw. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, _ := zw.Create("readme.txt") + _, _ = w.Write([]byte("hi")) + _ = zw.Close() + + _, err := ImportWorkspaceFrom(t.TempDir(), + bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err == nil { + t.Fatal("expected error for archive without project.odaw") + } + var we *Error + if !errors.As(err, &we) || we.Code != ErrProjectFileMissing { + t.Errorf("got err=%v; want code=%v", err, ErrProjectFileMissing) + } +} + +func TestImport_RejectsZipSlip(t *testing.T) { + // Craft a zip that contains a valid project.odaw plus a malicious + // entry trying to escape via "..". + pf := validProjectFileJSON(t, "Evil") + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w1, _ := zw.Create(ProjectFileName) + _, _ = w1.Write(pf) + w2, _ := zw.Create("../escape.txt") + _, _ = w2.Write([]byte("pwned")) + _ = zw.Close() + + dst := t.TempDir() + _, err := ImportWorkspaceFrom(dst, bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err == nil { + t.Fatal("expected error for zip-slip entry") + } + // Nothing should have been written above dst. + parent := filepath.Dir(dst) + entries, _ := os.ReadDir(parent) + for _, e := range entries { + if e.Name() == "escape.txt" { + t.Errorf("escape.txt was written to %s", parent) + } + } +} + +func TestImport_WrappedLayoutIsAccepted(t *testing.T) { + // Simulate a user who zipped the workspace *folder* rather than + // its contents: project.odaw is nested one level deep under a + // prefix directory. + pf := validProjectFileJSON(t, "Wrapped") + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + _, _ = zw.Create("wrapper/") + wProj, _ := zw.Create("wrapper/" + ProjectFileName) + _, _ = wProj.Write(pf) + wSample, _ := zw.Create("wrapper/template/sample.wav") + _, _ = wSample.Write([]byte("RIFF")) + _ = zw.Close() + + dst := t.TempDir() + ws, err := ImportWorkspaceFrom(dst, bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("import: %v", err) + } + if ws.Project.Name != "Wrapped" { + t.Errorf("Name = %q", ws.Project.Name) + } + if _, err := os.Stat(filepath.Join(ws.Paths.Template, "sample.wav")); err != nil { + t.Errorf("nested file not extracted under correct prefix: %v", err) + } +} + +func TestImport_FreshIDPreventsCollision(t *testing.T) { + root := t.TempDir() + ws := makeWorkspaceOnDisk(t, root, "Collide") + zipPath := filepath.Join(t.TempDir(), "out.zip") + if err := ExportWorkspace(ws, zipPath); err != nil { + t.Fatal(err) + } + // Import the same archive twice into the same projects root. + a, err := ImportWorkspace(root, zipPath) + if err != nil { + t.Fatal(err) + } + b, err := ImportWorkspace(root, zipPath) + if err != nil { + t.Fatalf("second import should not collide: %v", err) + } + if a.Project.ID == b.Project.ID { + t.Errorf("imports share the same ID: %s", a.Project.ID) + } + if a.Paths.Root == b.Paths.Root { + t.Errorf("imports share the same root path: %s", a.Paths.Root) + } +} + +func TestSuggestExportFileName(t *testing.T) { + cases := []struct { + name string + ws *Workspace + want string + }{ + {"nil", nil, "workspace.zip"}, + {"normal", &Workspace{Project: &ProjectFile{Name: "My Song"}}, "my-song.zip"}, + {"blank falls back to slug default", &Workspace{Project: &ProjectFile{Name: ""}}, "project.zip"}, + } + for _, c := range cases { + if got := SuggestExportFileName(c.ws); got != c.want { + t.Errorf("%s: got %q, want %q", c.name, got, c.want) + } + } +} + +// --- small helpers ------------------------------------------------------ + +func validProjectFileJSON(t *testing.T, name string) []byte { + t.Helper() + pf := NewProjectFile(NewID(name), name, + TemplateRef{DAW: DAWFLStudio, ID: FLStudioTemplateID, Version: FLStudioTemplateVersion}, + time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + data, err := json.MarshalIndent(pf, "", " ") + if err != nil { + t.Fatal(err) + } + return data +} From ac226e97510a22d93980e05d4a0a9c18254a00a5 Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Tue, 21 Apr 2026 17:26:40 +0200 Subject: [PATCH 5/8] Implement last opened workspace functionality --- CHANGELOG.md | 6 ++ app/internal/ui/start.go | 56 +++++++++++++- app/internal/ui/startviewmodel.go | 20 +++++ app/internal/ui/startviewmodel_test.go | 81 ++++++++++++++++++++ app/internal/ui/window.go | 1 + app/internal/workspace/lastopened.go | 78 +++++++++++++++++++ app/internal/workspace/lastopened_test.go | 91 +++++++++++++++++++++++ todo.md | 4 +- 8 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 app/internal/workspace/lastopened.go create mode 100644 app/internal/workspace/lastopened_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index feeb06c..c4b0554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,8 @@ +# 0.3.0 (Better Workspaces) +- Last opened workspace +- Search Workspace List +- Export/Import Workspaces as zip files that can be shared with other people +- Archive Workspaces to ignore them in the list and show them in a separate list of archived workspaces + # 0.1.0 & 0.2.0 - Initial release \ No newline at end of file diff --git a/app/internal/ui/start.go b/app/internal/ui/start.go index 38087e8..f0f93b2 100644 --- a/app/internal/ui/start.go +++ b/app/internal/ui/start.go @@ -27,6 +27,20 @@ type StartScreenCallbacks struct { // BuildStartScreen constructs the workspace overview. The caller owns the // window and must attach the returned object via SetContent. func BuildStartScreen(w fyne.Window, svm *StartViewModel, cb StartScreenCallbacks) fyne.CanvasObject { + origOpen, origCreate := cb.OnOpen, cb.OnCreate + cb.OnOpen = func(ws *workspace.Workspace) { + svm.MarkOpened(ws) + if origOpen != nil { + origOpen(ws) + } + } + cb.OnCreate = func(ws *workspace.Workspace) { + svm.MarkOpened(ws) + if origCreate != nil { + origCreate(ws) + } + } + content := container.NewStack() var rerender func() @@ -143,12 +157,52 @@ func buildWorkspaceList( } body := container.NewVScroll(rows) - if banner := maybeSkippedBanner(svm.Skipped()); banner != nil { + + var top fyne.CanvasObject + if !filtering { + if last, ok := svm.LastOpenedSummary(); ok { + top = buildLastOpenedSection(w, cb, last) + } + } + + banner := maybeSkippedBanner(svm.Skipped()) + switch { + case top != nil && banner != nil: + return container.NewBorder(container.NewVBox(top, banner), nil, nil, nil, body) + case top != nil: + return container.NewBorder(top, nil, nil, nil, body) + case banner != nil: return container.NewBorder(banner, nil, nil, nil, body) } return body } +func buildLastOpenedSection( + w fyne.Window, + cb StartScreenCallbacks, + s workspace.Summary, +) fyne.CanvasObject { + openBtn := widget.NewButton("Open", func() { + ws, err := workspace.LoadWorkspace(s.Root) + if err != nil { + dialog.ShowError(err, w) + return + } + if cb.OnOpen != nil { + cb.OnOpen(ws) + } + }) + openBtn.Importance = widget.HighImportance + + meta := widget.NewLabel(formatMeta(s)) + meta.TextStyle = fyne.TextStyle{Italic: true} + meta.Wrapping = fyne.TextWrapWord + + body := container.NewBorder(nil, nil, nil, openBtn, meta) + card := widget.NewCard("Last opened", displayName(s.Name), body) + return container.NewPadded(card) +} + func workspaceRow(s workspace.Summary, onOpen, onExport func()) fyne.CanvasObject { name := widget.NewLabel(displayName(s.Name)) name.TextStyle = fyne.TextStyle{Bold: true} diff --git a/app/internal/ui/startviewmodel.go b/app/internal/ui/startviewmodel.go index 45d99dc..ae33de4 100644 --- a/app/internal/ui/startviewmodel.go +++ b/app/internal/ui/startviewmodel.go @@ -120,3 +120,23 @@ func (svm *StartViewModel) ExportWorkspaceToZip(summary workspace.Summary, destZ } return workspace.ExportWorkspace(ws, destZip) } + +func (svm *StartViewModel) MarkOpened(ws *workspace.Workspace) { + if ws == nil || ws.Project == nil { + return + } + _ = workspace.SaveLastOpened(svm.ProjectsRoot, ws.Project.ID) +} + +func (svm *StartViewModel) LastOpenedSummary() (workspace.Summary, bool) { + id, ok, _ := workspace.LoadLastOpened(svm.ProjectsRoot) + if !ok { + return workspace.Summary{}, false + } + for _, s := range svm.Workspaces() { + if s.ID == id { + return s, true + } + } + return workspace.Summary{}, false +} diff --git a/app/internal/ui/startviewmodel_test.go b/app/internal/ui/startviewmodel_test.go index 5547478..4bd7fd6 100644 --- a/app/internal/ui/startviewmodel_test.go +++ b/app/internal/ui/startviewmodel_test.go @@ -328,3 +328,84 @@ func TestStartVM_ExportImportRoundTrip(t *testing.T) { len(dstSVM.Workspaces())) } } + +func TestStartVM_LastOpened_MarksAndResolves(t *testing.T) { + root := t.TempDir() + svm := NewStartViewModel(root) + + // No history yet. + if _, ok := svm.LastOpenedSummary(); ok { + t.Error("fresh VM should not report a last opened workspace") + } + + first, err := createTestWorkspace(svm, "First") + if err != nil { + t.Fatal(err) + } + second, err := createTestWorkspace(svm, "Second") + if err != nil { + t.Fatal(err) + } + // Mark only the second one. + svm.MarkOpened(second) + if err := svm.Refresh(); err != nil { + t.Fatal(err) + } + got, ok := svm.LastOpenedSummary() + if !ok { + t.Fatal("expected a last-opened entry") + } + if got.ID != second.Project.ID { + t.Errorf("last opened = %q, want %q", got.ID, second.Project.ID) + } + _ = first +} + +func TestStartVM_LastOpened_IgnoresDeletedWorkspace(t *testing.T) { + root := t.TempDir() + svm := NewStartViewModel(root) + + ws, err := createTestWorkspace(svm, "Doomed") + if err != nil { + t.Fatal(err) + } + svm.MarkOpened(ws) + + // Nuke the workspace directory, refresh, then query. + if err := os.RemoveAll(ws.Paths.Root); err != nil { + t.Fatal(err) + } + if err := svm.Refresh(); err != nil { + t.Fatal(err) + } + if _, ok := svm.LastOpenedSummary(); ok { + t.Error("last opened must be hidden when target workspace no longer exists") + } +} + +func TestStartVM_LastOpened_OnlyOneAtATime(t *testing.T) { + root := t.TempDir() + svm := NewStartViewModel(root) + + a, _ := createTestWorkspace(svm, "A") + b, _ := createTestWorkspace(svm, "B") + c, _ := createTestWorkspace(svm, "C") + + svm.MarkOpened(a) + svm.MarkOpened(b) + svm.MarkOpened(c) + _ = svm.Refresh() + + got, ok := svm.LastOpenedSummary() + if !ok || got.ID != c.Project.ID { + t.Errorf("expected last opened = C (%q), got ok=%v id=%q", + c.Project.ID, ok, got.ID) + } +} + +func TestStartVM_LastOpened_NilSafe(t *testing.T) { + svm := NewStartViewModel(t.TempDir()) + // Must not panic. + svm.MarkOpened(nil) + svm.MarkOpened(&workspace.Workspace{}) +} diff --git a/app/internal/ui/window.go b/app/internal/ui/window.go index 29dfc68..ae7fa8e 100644 --- a/app/internal/ui/window.go +++ b/app/internal/ui/window.go @@ -77,6 +77,7 @@ func RunWithOpenPath(openProjectPath string) { ws, err := workspace.LoadWorkspaceFromProjectFile(openProjectPath) if err == nil { activeWorkspace = ws + _ = workspace.SaveLastOpened(projectsRoot, ws.Project.ID) showMain(w, showStart) w.ShowAndRun() return diff --git a/app/internal/workspace/lastopened.go b/app/internal/workspace/lastopened.go new file mode 100644 index 0000000..7390763 --- /dev/null +++ b/app/internal/workspace/lastopened.go @@ -0,0 +1,78 @@ +package workspace + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +const lastOpenedFileName = ".last_opened.json" + +type LastOpenedRecord struct { + ID ID `json:"id"` + At time.Time `json:"at,omitempty"` +} + +func SaveLastOpened(projectsRoot string, id ID) error { + if strings.TrimSpace(projectsRoot) == "" { + return nil + } + if strings.TrimSpace(string(id)) == "" { + return nil + } + if err := os.MkdirAll(projectsRoot, 0o755); err != nil { + return &Error{Code: ErrIO, + Message: "cannot create projects root for last-opened state", Cause: err} + } + rec := LastOpenedRecord{ID: id, At: time.Now().UTC()} + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return &Error{Code: ErrIO, + Message: "cannot marshal last-opened record", Cause: err} + } + target := filepath.Join(projectsRoot, lastOpenedFileName) + tmp := target + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return &Error{Code: ErrIO, + Message: "cannot write last-opened record: " + tmp, Cause: err} + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return &Error{Code: ErrIO, + Message: "cannot rename last-opened record into place", Cause: err} + } + return nil +} + +func LoadLastOpened(projectsRoot string) (ID, bool, error) { + target := filepath.Join(projectsRoot, lastOpenedFileName) + data, err := os.ReadFile(target) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", false, nil + } + return "", false, &Error{Code: ErrIO, + Message: "cannot read last-opened record: " + target, Cause: err} + } + var rec LastOpenedRecord + if err := json.Unmarshal(data, &rec); err != nil { + return "", false, nil + } + if strings.TrimSpace(string(rec.ID)) == "" { + return "", false, nil + } + return rec.ID, true, nil +} + +func ClearLastOpened(projectsRoot string) error { + target := filepath.Join(projectsRoot, lastOpenedFileName) + if err := os.Remove(target); err != nil && !errors.Is(err, fs.ErrNotExist) { + return &Error{Code: ErrIO, + Message: "cannot remove last-opened record: " + target, Cause: err} + } + return nil +} diff --git a/app/internal/workspace/lastopened_test.go b/app/internal/workspace/lastopened_test.go new file mode 100644 index 0000000..85458fc --- /dev/null +++ b/app/internal/workspace/lastopened_test.go @@ -0,0 +1,91 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLastOpened_RoundTrip(t *testing.T) { + root := t.TempDir() + + if _, ok, err := LoadLastOpened(root); err != nil || ok { + t.Fatalf("fresh root should return (_, false, nil); got (_, %v, %v)", ok, err) + } + + id := ID("my-song-abcdef") + if err := SaveLastOpened(root, id); err != nil { + t.Fatalf("save: %v", err) + } + got, ok, err := LoadLastOpened(root) + if err != nil { + t.Fatalf("load: %v", err) + } + if !ok || got != id { + t.Errorf("got (%q, %v), want (%q, true)", got, ok, id) + } +} + +func TestLastOpened_Overwrites(t *testing.T) { + root := t.TempDir() + _ = SaveLastOpened(root, "first-id") + _ = SaveLastOpened(root, "second-id") + got, _, _ := LoadLastOpened(root) + if got != "second-id" { + t.Errorf("got %q, want second-id", got) + } +} + +func TestLastOpened_EmptyInputsAreNoOps(t *testing.T) { + if err := SaveLastOpened("", "x"); err != nil { + t.Errorf("empty root should be no-op: %v", err) + } + if err := SaveLastOpened(t.TempDir(), ""); err != nil { + t.Errorf("empty id should be no-op: %v", err) + } +} + +func TestLastOpened_CorruptFileTreatedAsNone(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, lastOpenedFileName), + []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + id, ok, err := LoadLastOpened(root) + if err != nil { + t.Errorf("corrupt file should not error, got %v", err) + } + if ok || id != "" { + t.Errorf("corrupt file should yield (\"\", false), got (%q, %v)", id, ok) + } +} + +func TestLastOpened_Clear(t *testing.T) { + root := t.TempDir() + _ = SaveLastOpened(root, "to-be-cleared") + + if err := ClearLastOpened(root); err != nil { + t.Fatalf("clear: %v", err) + } + _, ok, _ := LoadLastOpened(root) + if ok { + t.Error("after clear, LoadLastOpened should return false") + } + // Second clear is a no-op. + if err := ClearLastOpened(root); err != nil { + t.Errorf("second clear should be no-op: %v", err) + } +} + +func TestLastOpened_DoesNotAppearAsWorkspace(t *testing.T) { + root := t.TempDir() + _ = SaveLastOpened(root, "x-y-z") + + res, err := ListWorkspaces(root) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(res.Workspaces) != 0 || len(res.Skipped) != 0 { + t.Errorf("state file must not surface in workspace listing, got %+v", res) + } +} diff --git a/todo.md b/todo.md index ed832ca..49ec2b6 100644 --- a/todo.md +++ b/todo.md @@ -1,8 +1,8 @@ # Better Workspaces (Planned Release: 0.3.0) -- Last opened workspace +- ✅ Last opened workspace - ✅ Search Workspace List - Archive Workspaces to ignore them in the list and show them in a separate list of archived workspaces -- Export Workspaces as zip files that can be shared with other people. This zip file would include the workspace settings, the DAW template with the hitsound structure, and the generated diff export. +- ✅ Export Workspaces as zip files that can be shared with other people. This zip file would include the workspace settings, the DAW template with the hitsound structure, and the generated diff export. # UX Improvements (Planned Release: 0.3.0) - Better Feedback for successful actions (e.g. "Hitsound diff generated successfully!" notification after generating the diff) From 846ca2d69bbf45182313d72df9c12030110c213b Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Tue, 21 Apr 2026 17:36:44 +0200 Subject: [PATCH 6/8] Add workspace archiving functionality --- app/internal/ui/start.go | 208 ++++++++++++++----- app/internal/ui/startviewmodel.go | 30 +++ app/internal/ui/startviewmodel_test.go | 92 ++++++++ app/internal/workspace/archive_state.go | 17 ++ app/internal/workspace/archive_state_test.go | 115 ++++++++++ app/internal/workspace/list.go | 19 +- app/internal/workspace/workspace.go | 5 +- todo.md | 2 +- 8 files changed, 424 insertions(+), 64 deletions(-) create mode 100644 app/internal/workspace/archive_state.go create mode 100644 app/internal/workspace/archive_state_test.go diff --git a/app/internal/ui/start.go b/app/internal/ui/start.go index f0f93b2..275f076 100644 --- a/app/internal/ui/start.go +++ b/app/internal/ui/start.go @@ -78,7 +78,7 @@ func BuildStartScreen(w fyne.Window, svm *StartViewModel, cb StartScreenCallback ) rerender = func() { - body := buildWorkspaceList(w, svm, cb) + body := buildWorkspaceList(w, svm, cb, rerender) content.Objects = []fyne.CanvasObject{ container.NewBorder( container.NewVBox( @@ -102,79 +102,169 @@ func BuildStartScreen(w fyne.Window, svm *StartViewModel, cb StartScreenCallback return content } -// buildWorkspaceList renders the current workspace list (or an empty -// state) plus a warning banner for skipped entries. +// buildWorkspaceList renders the active workspace list (or an empty +// state) plus — when present — a "Last opened" shortcut above and an +// "Archived" accordion below. rerender is threaded through so per-row +// archive toggles can refresh the screen. func buildWorkspaceList( w fyne.Window, svm *StartViewModel, cb StartScreenCallbacks, + rerender func(), ) fyne.CanvasObject { - items := svm.FilteredWorkspaces() - total := len(svm.Workspaces()) + activeItems := svm.FilteredWorkspaces() + totalActive := len(svm.Workspaces()) + totalArchived := len(svm.Archived()) filtering := strings.TrimSpace(svm.SearchQuery) != "" - if len(items) == 0 { - var msg string - switch { - case filtering && total > 0: - msg = fmt.Sprintf( - "No workspaces match %q.\n\nTry a different search term or clear the search.", - svm.SearchQuery, - ) - default: - msg = "No workspaces yet.\n\nClick “Create New Workspace” to start a new project." + // --- center: active list or empty state ----------------------------- + var center fyne.CanvasObject + if len(activeItems) == 0 { + center = container.NewCenter(container.NewVBox( + emptyStateLabel(svm.SearchQuery, filtering, totalActive, totalArchived), + )) + } else { + rows := container.NewVBox() + for i := range activeItems { + item := activeItems[i] + rows.Add(workspaceRow(item, + func() { openSummary(w, cb, item) }, + func() { showExportWorkspaceDialog(w, svm, item) }, + func() { archiveSummary(w, svm, item, true, rerender) }, + )) + rows.Add(vSpace(4)) } - empty := widget.NewLabel(msg) - empty.Wrapping = fyne.TextWrapWord - empty.Alignment = fyne.TextAlignCenter + center = container.NewVScroll(rows) + } - children := []fyne.CanvasObject{empty} - if banner := maybeSkippedBanner(svm.Skipped()); banner != nil { - children = append(children, banner) + // --- top: last-opened shortcut + skipped banner -------------------- + var top fyne.CanvasObject + if !filtering { + if last, ok := svm.LastOpenedSummary(); ok { + top = buildLastOpenedSection(w, cb, last) } - return container.NewCenter(container.NewVBox(children...)) + } + if banner := maybeSkippedBanner(svm.Skipped()); banner != nil { + top = stackIfBoth(top, banner) } - rows := container.NewVBox() - for i := range items { - item := items[i] - rows.Add(workspaceRow(item, - func() { - ws, err := workspace.LoadWorkspace(item.Root) - if err != nil { - dialog.ShowError(err, w) - return - } - if cb.OnOpen != nil { - cb.OnOpen(ws) - } - }, - func() { - showExportWorkspaceDialog(w, svm, item) - }, - )) - rows.Add(vSpace(4)) + // --- bottom: archived accordion ------------------------------------ + var bottom fyne.CanvasObject + if totalArchived > 0 { + bottom = buildArchivedSection( + w, svm, cb, svm.FilteredArchived(), totalArchived, filtering, rerender) } - body := container.NewVScroll(rows) + return container.NewBorder(top, bottom, nil, nil, center) +} - var top fyne.CanvasObject - if !filtering { - if last, ok := svm.LastOpenedSummary(); ok { - top = buildLastOpenedSection(w, cb, last) +// emptyStateLabel picks the right "nothing to show" message based on +// whether the user is filtering and whether archived workspaces exist +// that could explain the empty active list. +func emptyStateLabel(query string, filtering bool, totalActive, totalArchived int) *widget.Label { + var msg string + switch { + case filtering && totalActive+totalArchived > 0: + msg = fmt.Sprintf( + "No workspaces match %q.\n\nTry a different search term or clear the search.", + query, + ) + case totalActive == 0 && totalArchived > 0: + msg = "No active workspaces.\n\nAll your workspaces are currently archived. " + + "Expand “Archived” below to restore one, or create a new workspace." + default: + msg = "No workspaces yet.\n\nClick “Create New Workspace” to start a new project." + } + lbl := widget.NewLabel(msg) + lbl.Wrapping = fyne.TextWrapWord + lbl.Alignment = fyne.TextAlignCenter + return lbl +} + +// buildArchivedSection renders a collapsed accordion titled +// "Archived (N)" (or "Archived (m of N)" while filtering). Expanding it +// reveals archived workspace cards with Unarchive actions. +func buildArchivedSection( + w fyne.Window, + svm *StartViewModel, + cb StartScreenCallbacks, + items []workspace.Summary, + totalArchived int, + filtering bool, + rerender func(), +) fyne.CanvasObject { + var content fyne.CanvasObject + if len(items) == 0 { + lbl := widget.NewLabel(fmt.Sprintf( + "No archived workspaces match %q.", svm.SearchQuery)) + lbl.Wrapping = fyne.TextWrapWord + lbl.Alignment = fyne.TextAlignCenter + content = container.NewPadded(lbl) + } else { + rows := container.NewVBox() + for i := range items { + item := items[i] + rows.Add(workspaceRow(item, + func() { openSummary(w, cb, item) }, + func() { showExportWorkspaceDialog(w, svm, item) }, + func() { archiveSummary(w, svm, item, false, rerender) }, + )) + rows.Add(vSpace(4)) } + content = rows } - banner := maybeSkippedBanner(svm.Skipped()) + title := fmt.Sprintf("Archived (%d)", totalArchived) + if filtering { + title = fmt.Sprintf("Archived (%d of %d)", len(items), totalArchived) + } + acc := widget.NewAccordion(widget.NewAccordionItem(title, content)) + return container.NewPadded(acc) +} + +// stackIfBoth vertically concatenates two optional objects. Returns +// nil when both are nil, the surviving object when exactly one is nil, +// and a VBox otherwise. +func stackIfBoth(a, b fyne.CanvasObject) fyne.CanvasObject { switch { - case top != nil && banner != nil: - return container.NewBorder(container.NewVBox(top, banner), nil, nil, nil, body) - case top != nil: - return container.NewBorder(top, nil, nil, nil, body) - case banner != nil: - return container.NewBorder(banner, nil, nil, nil, body) + case a == nil && b == nil: + return nil + case a == nil: + return b + case b == nil: + return a + default: + return container.NewVBox(a, b) + } +} + +// openSummary resolves a Summary to a Workspace and hands it to OnOpen. +func openSummary(w fyne.Window, cb StartScreenCallbacks, s workspace.Summary) { + ws, err := workspace.LoadWorkspace(s.Root) + if err != nil { + dialog.ShowError(err, w) + return + } + if cb.OnOpen != nil { + cb.OnOpen(ws) } - return body +} + +// archiveSummary flips the archived flag and rerenders so the workspace +// moves between the active list and the archived accordion without the +// user needing to refresh manually. +func archiveSummary( + w fyne.Window, + svm *StartViewModel, + s workspace.Summary, + archived bool, + rerender func(), +) { + if err := svm.SetArchived(s, archived); err != nil { + dialog.ShowError(err, w) + return + } + rerender() } func buildLastOpenedSection( @@ -203,7 +293,7 @@ func buildLastOpenedSection( return container.NewPadded(card) } -func workspaceRow(s workspace.Summary, onOpen, onExport func()) fyne.CanvasObject { +func workspaceRow(s workspace.Summary, onOpen, onExport, onArchiveToggle func()) fyne.CanvasObject { name := widget.NewLabel(displayName(s.Name)) name.TextStyle = fyne.TextStyle{Bold: true} @@ -212,6 +302,12 @@ func workspaceRow(s workspace.Summary, onOpen, onExport func()) fyne.CanvasObjec exportBtn := widget.NewButton("Export…", onExport) + archiveLabel := "Archive" + if s.Archived { + archiveLabel = "Unarchive" + } + archiveBtn := widget.NewButton(archiveLabel, onArchiveToggle) + meta := widget.NewLabel(formatMeta(s)) meta.TextStyle = fyne.TextStyle{Italic: true} meta.Wrapping = fyne.TextWrapWord @@ -220,7 +316,7 @@ func workspaceRow(s workspace.Summary, onOpen, onExport func()) fyne.CanvasObjec path.TextStyle = fyne.TextStyle{Italic: true} path.Wrapping = fyne.TextWrapWord - actions := container.NewHBox(exportBtn, openBtn) + actions := container.NewHBox(archiveBtn, exportBtn, openBtn) body := container.NewVBox( container.NewBorder(nil, nil, nil, actions, name), diff --git a/app/internal/ui/startviewmodel.go b/app/internal/ui/startviewmodel.go index ae33de4..4b74c7f 100644 --- a/app/internal/ui/startviewmodel.go +++ b/app/internal/ui/startviewmodel.go @@ -140,3 +140,33 @@ func (svm *StartViewModel) LastOpenedSummary() (workspace.Summary, bool) { } return workspace.Summary{}, false } + +func (svm *StartViewModel) Archived() []workspace.Summary { + if svm.lastList == nil { + return nil + } + return svm.lastList.Archived +} + +func (svm *StartViewModel) FilteredArchived() []workspace.Summary { + all := svm.Archived() + q := strings.TrimSpace(strings.ToLower(svm.SearchQuery)) + if q == "" { + return all + } + out := make([]workspace.Summary, 0, len(all)) + for _, s := range all { + if strings.Contains(strings.ToLower(s.Name), q) { + out = append(out, s) + } + } + return out +} + +func (svm *StartViewModel) SetArchived(summary workspace.Summary, archived bool) error { + paths := workspace.PathsFromRoot(summary.Root) + if err := workspace.SetArchived(paths, archived); err != nil { + return err + } + return svm.Refresh() +} diff --git a/app/internal/ui/startviewmodel_test.go b/app/internal/ui/startviewmodel_test.go index 4bd7fd6..f1f2755 100644 --- a/app/internal/ui/startviewmodel_test.go +++ b/app/internal/ui/startviewmodel_test.go @@ -409,3 +409,95 @@ func TestStartVM_LastOpened_NilSafe(t *testing.T) { svm.MarkOpened(nil) svm.MarkOpened(&workspace.Workspace{}) } + +func TestStartVM_Archive_MovesBetweenLists(t *testing.T) { + root := t.TempDir() + svm := NewStartViewModel(root) + + a, err := createTestWorkspace(svm, "Keeps") + if err != nil { + t.Fatal(err) + } + b, err := createTestWorkspace(svm, "Hides") + if err != nil { + t.Fatal(err) + } + if err := svm.Refresh(); err != nil { + t.Fatal(err) + } + if len(svm.Workspaces()) != 2 || len(svm.Archived()) != 0 { + t.Fatalf("setup: active=%d archived=%d", + len(svm.Workspaces()), len(svm.Archived())) + } + + // Archive b. + var bSummary workspace.Summary + for _, s := range svm.Workspaces() { + if s.ID == b.Project.ID { + bSummary = s + } + } + if err := svm.SetArchived(bSummary, true); err != nil { + t.Fatalf("archive: %v", err) + } + if got := len(svm.Workspaces()); got != 1 || svm.Workspaces()[0].ID != a.Project.ID { + t.Errorf("active after archive: %+v", svm.Workspaces()) + } + if got := len(svm.Archived()); got != 1 || svm.Archived()[0].ID != b.Project.ID { + t.Errorf("archived after archive: %+v", svm.Archived()) + } + + // Unarchive b. + if err := svm.SetArchived(svm.Archived()[0], false); err != nil { + t.Fatalf("unarchive: %v", err) + } + if len(svm.Workspaces()) != 2 || len(svm.Archived()) != 0 { + t.Errorf("after unarchive: active=%d archived=%d", + len(svm.Workspaces()), len(svm.Archived())) + } +} + +func TestStartVM_Archive_HidesFromLastOpened(t *testing.T) { + root := t.TempDir() + svm := NewStartViewModel(root) + + ws, err := createTestWorkspace(svm, "Forgotten") + if err != nil { + t.Fatal(err) + } + svm.MarkOpened(ws) + if _, ok := svm.LastOpenedSummary(); !ok { + t.Fatal("precondition: last-opened should resolve") + } + + // Archiving the last-opened workspace moves it out of Workspaces() + // and thus out of LastOpenedSummary's candidate pool. + summary := svm.Workspaces()[0] + if err := svm.SetArchived(summary, true); err != nil { + t.Fatal(err) + } + if _, ok := svm.LastOpenedSummary(); ok { + t.Error("archived workspace must not surface as last opened") + } +} + +func TestStartVM_FilteredArchived(t *testing.T) { + root := t.TempDir() + svm := NewStartViewModel(root) + + a, _ := createTestWorkspace(svm, "Alpha") + b, _ := createTestWorkspace(svm, "Beta Song") + _ = svm.SetArchived(workspace.Summary{Root: a.Paths.Root}, true) + _ = svm.SetArchived(workspace.Summary{Root: b.Paths.Root}, true) + + svm.SearchQuery = "song" + got := svm.FilteredArchived() + if len(got) != 1 || got[0].Name != "Beta Song" { + t.Errorf("filtered archived = %+v, want only Beta Song", got) + } + svm.SearchQuery = "" + if len(svm.FilteredArchived()) != 2 { + t.Errorf("empty query should return all archived, got %d", + len(svm.FilteredArchived())) + } +} diff --git a/app/internal/workspace/archive_state.go b/app/internal/workspace/archive_state.go new file mode 100644 index 0000000..28b8586 --- /dev/null +++ b/app/internal/workspace/archive_state.go @@ -0,0 +1,17 @@ +package workspace + +func SetArchived(paths Paths, archived bool) error { + pf, err := LoadProjectFile(paths) + if err != nil { + return err + } + if pf.Archived == archived { + return nil + } + pf.Archived = archived + return SaveProjectFile(paths, pf) +} + +func ArchiveWorkspace(paths Paths) error { return SetArchived(paths, true) } + +func UnarchiveWorkspace(paths Paths) error { return SetArchived(paths, false) } diff --git a/app/internal/workspace/archive_state_test.go b/app/internal/workspace/archive_state_test.go new file mode 100644 index 0000000..081e241 --- /dev/null +++ b/app/internal/workspace/archive_state_test.go @@ -0,0 +1,115 @@ +package workspace + +import ( + "path/filepath" + "testing" + "time" + + "osu-daws-app/internal/domain" +) + +func makeArchivableWorkspace(t *testing.T, root, name string) *Workspace { + t.Helper() + s := NewCreateService(root, NewDefaultCatalog()) + ws, err := s.Create(CreateRequest{ + Name: name, + Template: s.Catalog.Default(), + DefaultSampleset: domain.SamplesetSoft, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + return ws +} + +func TestArchiveState_DefaultsToFalse(t *testing.T) { + ws := makeArchivableWorkspace(t, t.TempDir(), "Fresh") + if ws.Project.Archived { + t.Error("fresh workspace must not be archived by default") + } + // Round-trip: loading from disk keeps Archived=false. + loaded, err := LoadWorkspace(ws.Paths.Root) + if err != nil { + t.Fatal(err) + } + if loaded.Project.Archived { + t.Error("reloaded workspace should keep Archived=false") + } +} + +func TestSetArchived_Roundtrip(t *testing.T) { + ws := makeArchivableWorkspace(t, t.TempDir(), "Subject") + + if err := ArchiveWorkspace(ws.Paths); err != nil { + t.Fatalf("archive: %v", err) + } + loaded, err := LoadWorkspace(ws.Paths.Root) + if err != nil { + t.Fatal(err) + } + if !loaded.Project.Archived { + t.Error("Archived=true did not persist") + } + + if err := UnarchiveWorkspace(ws.Paths); err != nil { + t.Fatalf("unarchive: %v", err) + } + loaded, err = LoadWorkspace(ws.Paths.Root) + if err != nil { + t.Fatal(err) + } + if loaded.Project.Archived { + t.Error("Archived=false did not persist") + } +} + +func TestSetArchived_NoOpWhenUnchanged(t *testing.T) { + ws := makeArchivableWorkspace(t, t.TempDir(), "Stable") + before := ws.Project.UpdatedAt + + // Sleep + write nothing, UpdatedAt must not advance. + time.Sleep(5 * time.Millisecond) + if err := SetArchived(ws.Paths, false); err != nil { + t.Fatal(err) + } + loaded, _ := LoadWorkspace(ws.Paths.Root) + if !loaded.Project.UpdatedAt.Equal(before) { + t.Errorf("UpdatedAt = %v, want unchanged %v", + loaded.Project.UpdatedAt, before) + } +} + +func TestListWorkspaces_SplitsArchived(t *testing.T) { + root := t.TempDir() + active := makeArchivableWorkspace(t, root, "Active") + archived := makeArchivableWorkspace(t, root, "Stashed") + if err := ArchiveWorkspace(archived.Paths); err != nil { + t.Fatal(err) + } + + res, err := ListWorkspaces(root) + if err != nil { + t.Fatal(err) + } + if len(res.Workspaces) != 1 || res.Workspaces[0].Name != "Active" { + t.Errorf("active list = %+v", res.Workspaces) + } + if len(res.Archived) != 1 || res.Archived[0].Name != "Stashed" { + t.Errorf("archived list = %+v", res.Archived) + } + // Archived flag propagates onto the summary. + if !res.Archived[0].Archived || res.Workspaces[0].Archived { + t.Errorf("Archived flag inconsistent: active=%v archived=%v", + res.Workspaces[0].Archived, res.Archived[0].Archived) + } + _ = active +} + +func TestSetArchived_RejectsMissingProjectFile(t *testing.T) { + // No workspace scaffolded under this path; SetArchived must surface + // a structured error rather than create a stub project.odaw. + bogus := PathsFromRoot(filepath.Join(t.TempDir(), "does", "not", "exist")) + if err := SetArchived(bogus, true); err == nil { + t.Error("expected error for missing project file") + } +} diff --git a/app/internal/workspace/list.go b/app/internal/workspace/list.go index 5a80cde..46947d9 100644 --- a/app/internal/workspace/list.go +++ b/app/internal/workspace/list.go @@ -18,6 +18,7 @@ type Summary struct { ReferenceOsuPath string UpdatedAt time.Time Root string + Archived bool } // SkippedEntry describes a directory that looked like a workspace but @@ -28,10 +29,13 @@ type SkippedEntry struct { Err error } -// ListResult bundles successful summaries and skipped entries. The -// Workspaces slice is sorted by UpdatedAt desc, then Name asc, then ID asc. +// ListResult bundles successful summaries and skipped entries. +// Workspaces contains only non-archived entries; Archived is populated +// with the rest so callers can render them in a separate section. +// Both slices share the same sort order: UpdatedAt desc, Name asc, ID asc. type ListResult struct { Workspaces []Summary + Archived []Summary Skipped []SkippedEntry } @@ -70,17 +74,24 @@ func ListWorkspaces(projectsRoot string) (*ListResult, error) { continue } - result.Workspaces = append(result.Workspaces, Summary{ + s := Summary{ ID: pf.ID, Name: pf.Name, DAW: pf.Template.DAW, ReferenceOsuPath: pf.ReferenceOsuPath, UpdatedAt: pf.UpdatedAt, Root: root, - }) + Archived: pf.Archived, + } + if pf.Archived { + result.Archived = append(result.Archived, s) + } else { + result.Workspaces = append(result.Workspaces, s) + } } sortSummaries(result.Workspaces) + sortSummaries(result.Archived) sort.SliceStable(result.Skipped, func(i, j int) bool { return result.Skipped[i].Path < result.Skipped[j].Path }) diff --git a/app/internal/workspace/workspace.go b/app/internal/workspace/workspace.go index bcb1e33..9718cf3 100644 --- a/app/internal/workspace/workspace.go +++ b/app/internal/workspace/workspace.go @@ -79,9 +79,6 @@ type SegmentInput struct { Label string `json:"label,omitempty"` } -// ProjectFile is the on-disk model of project.odaw. It is serialised as -// JSON with stable field tags so new optional fields can be added with -// `omitempty` without breaking older projects. type ProjectFile struct { Version int `json:"version"` @@ -95,6 +92,8 @@ type ProjectFile struct { Segments []SegmentInput `json:"segments"` + Archived bool `json:"archived,omitempty"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/todo.md b/todo.md index 49ec2b6..77d1167 100644 --- a/todo.md +++ b/todo.md @@ -1,7 +1,7 @@ # Better Workspaces (Planned Release: 0.3.0) - ✅ Last opened workspace - ✅ Search Workspace List -- Archive Workspaces to ignore them in the list and show them in a separate list of archived workspaces +- ✅ Archive Workspaces to ignore them in the list and show them in a separate list of archived workspaces - ✅ Export Workspaces as zip files that can be shared with other people. This zip file would include the workspace settings, the DAW template with the hitsound structure, and the generated diff export. # UX Improvements (Planned Release: 0.3.0) From 54cd20db8288aa5d958594db0d3677e66b89fc80 Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Wed, 22 Apr 2026 20:44:52 +0200 Subject: [PATCH 7/8] Add notification for successful hitsound diff generation --- CHANGELOG.md | 1 + app/internal/ui/window.go | 5 +++++ todo.md | 26 +++++++++++++------------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b0554..ff332f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Search Workspace List - Export/Import Workspaces as zip files that can be shared with other people - Archive Workspaces to ignore them in the list and show them in a separate list of archived workspaces +- Better Feedback for successful actions ( "Hitsound diff generated successfully!" notification) # 0.1.0 & 0.2.0 - Initial release \ No newline at end of file diff --git a/app/internal/ui/window.go b/app/internal/ui/window.go index ae7fa8e..c502c32 100644 --- a/app/internal/ui/window.go +++ b/app/internal/ui/window.go @@ -226,6 +226,11 @@ func buildAndShow(w fyne.Window, vm *ViewModel, backToStart func()) { output.SetText(res.OsuContent) copyToOsuBtn.Enable() + fyne.CurrentApp().SendNotification(&fyne.Notification{ + Title: "osu!daws", + Content: "Hitsound diff generated successfully!", + }) + if vm.WorkspaceExportsDir() != "" { path, saveErr := vm.SaveToExports(res) if saveErr != nil { diff --git a/todo.md b/todo.md index 77d1167..1354ef0 100644 --- a/todo.md +++ b/todo.md @@ -5,11 +5,17 @@ - ✅ Export Workspaces as zip files that can be shared with other people. This zip file would include the workspace settings, the DAW template with the hitsound structure, and the generated diff export. # UX Improvements (Planned Release: 0.3.0) -- Better Feedback for successful actions (e.g. "Hitsound diff generated successfully!" notification after generating the diff) +- ✅ Better Feedback for successful actions (e.g. "Hitsound diff generated successfully!" notification after generating the diff) -# Better Section Management (Planned Release: 0.4.0) -- User is able to Rename Sections for better organization (e.g. "Intro", "Verse", "Chorus", etc.) -- Enable or Disable sections to easily test different versions of the hitsound structure in-game without having to delete or move notes around in the DAW +# Better Exports (Planned Release: 0.4.0) +- New Button to directly open the generated diff in osu! Editor after generation +- Versionate Hitsound Diff exports v1, v2, ... +- Statistic after generation: number of hitsounds, number of custom sample sets, etc. +- Better Volume Distribution Option + - One of the problems is, that volume percentages can differ ~2-3% because in FL you have to klick the exact same pixel which can sometimes be hard. + - A better option would be to have a "Volume Step Size" setting in osu!daws which defines the step size for volume changes. + - For example, if you set it to 5%, then any velocity that falls within a 5% range would be rounded to the nearest step. So if you have a velocity that corresponds to 72%, and your step size is 5%, it would round to either 70% or 75% depending on which one is closer. + - This way, you can ensure more consistent volume levels without having to worry about pixel-perfect clicks in FL Studio. # Settings-Section (Planned Release: 0.5.0) - Add a helper to install the FL Studio Script via osu!daws @@ -20,12 +26,6 @@ - Default gamemode for HS diff: osu!standard or Catch - Automatically open last workspace on app start option -# Better Exports (Planned Release: 1.0.0) -- New Button to directly open the generated diff in osu! Editor after generation -- Versionate Hitsound Diff exports v1, v2, ... -- Statistic after generation: number of hitsounds, number of custom sample sets, etc. -- Better Volume Distribution Option - - One of the problems is, that volume percentages can differ ~2-3% because in FL you have to klick the exact same pixel which can sometimes be hard. - - A better option would be to have a "Volume Step Size" setting in osu!daws which defines the step size for volume changes. - - For example, if you set it to 5%, then any velocity that falls within a 5% range would be rounded to the nearest step. So if you have a velocity that corresponds to 72%, and your step size is 5%, it would round to either 70% or 75% depending on which one is closer. - - This way, you can ensure more consistent volume levels without having to worry about pixel-perfect clicks in FL Studio. +# Better Section Management (Planned Release: 0.5.0) +- User is able to Rename Sections for better organization (e.g. "Intro", "Verse", "Chorus", etc.) +- Enable or Disable sections to easily test different versions of the hitsound structure in-game without having to delete or move notes around in the DAW From 9bc8728f56a2c42f408a2ec8e83f0261e1461fc4 Mon Sep 17 00:00:00 2001 From: Shukaaa Date: Wed, 22 Apr 2026 20:50:10 +0200 Subject: [PATCH 8/8] Add CI configuration for automated testing and building during Pull Reqeusts --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..891ed02 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + branches: ["**"] + push: + branches-ignore: + - master + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: Install · Vet · Test · Build + runs-on: windows-latest + defaults: + run: + working-directory: app + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: app/go.sum + + - name: Download modules + run: go mod download + + - name: Vet + env: + CGO_ENABLED: "1" + run: go vet ./... + + - name: Test + env: + CGO_ENABLED: "1" + run: go test -v -count=1 ./... + + - name: Build + env: + CGO_ENABLED: "1" + run: go build -v ./...