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 ./... diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ff332f3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# 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 +- 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/start.go b/app/internal/ui/start.go index a154553..275f076 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" @@ -26,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() @@ -43,15 +58,27 @@ 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) + searchEntry.OnChanged = func(s string) { + svm.SearchQuery = s + rerender() + } + header := container.NewBorder( nil, nil, sectionTitle("Workspaces"), - container.NewHBox(refreshBtn, createBtn), - nil, + container.NewHBox(refreshBtn, importBtn, createBtn), + searchEntry, ) rerender = func() { - body := buildWorkspaceList(w, svm, cb) + body := buildWorkspaceList(w, svm, cb, rerender) content.Objects = []fyne.CanvasObject{ container.NewBorder( container.NewVBox( @@ -75,59 +102,212 @@ 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.Workspaces() + activeItems := svm.FilteredWorkspaces() + totalActive := len(svm.Workspaces()) + totalArchived := len(svm.Archived()) + filtering := strings.TrimSpace(svm.SearchQuery) != "" + + // --- 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)) + } + center = container.NewVScroll(rows) + } - if len(items) == 0 { - empty := widget.NewLabel( - "No workspaces yet.\n\nClick “Create New Workspace” to start a new project.", + // --- top: last-opened shortcut + skipped banner -------------------- + var top fyne.CanvasObject + if !filtering { + if last, ok := svm.LastOpenedSummary(); ok { + top = buildLastOpenedSection(w, cb, last) + } + } + if banner := maybeSkippedBanner(svm.Skipped()); banner != nil { + top = stackIfBoth(top, banner) + } + + // --- bottom: archived accordion ------------------------------------ + var bottom fyne.CanvasObject + if totalArchived > 0 { + bottom = buildArchivedSection( + w, svm, cb, svm.FilteredArchived(), totalArchived, filtering, rerender) + } + + return container.NewBorder(top, bottom, nil, nil, center) +} + +// 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, ) - empty.Wrapping = fyne.TextWrapWord - empty.Alignment = fyne.TextAlignCenter + 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 +} - children := []fyne.CanvasObject{empty} - if banner := maybeSkippedBanner(svm.Skipped()); banner != nil { - children = append(children, banner) +// 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)) } - return container.NewCenter(container.NewVBox(children...)) + content = rows } - 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(vSpace(4)) + 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) +} - body := container.NewVScroll(rows) - if banner := maybeSkippedBanner(svm.Skipped()); banner != nil { - return container.NewBorder(banner, nil, nil, nil, body) +// 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 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) + } +} + +// 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 } - return body + rerender() } -func workspaceRow(s workspace.Summary, onOpen func()) fyne.CanvasObject { +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, onArchiveToggle 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) + + 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 @@ -136,8 +316,10 @@ func workspaceRow(s workspace.Summary, onOpen func()) fyne.CanvasObject { path.TextStyle = fyne.TextStyle{Italic: true} path.Wrapping = fyne.TextWrapWord + actions := container.NewHBox(archiveBtn, exportBtn, openBtn) + body := container.NewVBox( - container.NewBorder(nil, nil, nil, openBtn, name), + container.NewBorder(nil, nil, nil, actions, name), path, meta, ) @@ -358,3 +540,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 3bf8c5b..4b74c7f 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 { @@ -82,3 +103,70 @@ 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) +} + +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 +} + +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 83cff73..f1f2755 100644 --- a/app/internal/ui/startviewmodel_test.go +++ b/app/internal/ui/startviewmodel_test.go @@ -224,3 +224,280 @@ 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 +} + +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())) + } +} + +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{}) +} + +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/ui/window.go b/app/internal/ui/window.go index 29dfc68..c502c32 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 @@ -225,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/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_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/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 +} 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/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/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) + }) +} 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 5375844..1354ef0 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 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 +- 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 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