diff --git a/.github/config/labeler.yml b/.github/config/labeler.yml index 5ca3dae..002e383 100644 --- a/.github/config/labeler.yml +++ b/.github/config/labeler.yml @@ -20,7 +20,7 @@ Renderer: tests: - changed-files: - any-glob-to-any-file: - - tests/** + - Tests/** - '**/*Tests.cs' documentation: diff --git a/Docs/Development/Debug.md b/Docs/Development/Debug.md new file mode 100644 index 0000000..af05217 --- /dev/null +++ b/Docs/Development/Debug.md @@ -0,0 +1,17 @@ +# Debug + +`debdev` commands are for development. Use `ws play` for normal playback. + +```bash +./changetrace debdev player +./changetrace debdev render +./changetrace debdev window +``` + +| Command | Purpose | +| --- | --- | +| `debdev player ` | Debug the player. | +| `debdev render ` | Debug rendering. | +| `debdev window ` | Debug the OpenTK window. | + +`TimelineLoader` in `src/Cli/Commands/Debug/TimelineLoaderDebug.cs` is a helper, not a CLI command. diff --git a/Docs/Development/Project-Structure.md b/Docs/Development/Project-Structure.md new file mode 100644 index 0000000..e7fbf0b --- /dev/null +++ b/Docs/Development/Project-Structure.md @@ -0,0 +1,52 @@ +# Project Structure + +## Main Paths + +| Path | Role | +| --- | --- | +| `src/Cli` | Commands, handlers, prompts. | +| `src/Configuration` | DI and app startup. | +| `src/Core` | Domain model and timeline. | +| `src/CredentialTrace` | Auth, profiles, workspace storage. | +| `src/GIt` | Git reading and export. | +| `src/Graphics` | OpenTK, GPU, shaders. | +| `src/Player` | Timeline playback. | +| `src/Rendering` | Scene and render commands. | +| `Tools/` | Helper tools project. | +| `Benchmarks/` | Benchmarks project. | +| `Tests/` | Test project. | +| `Docs/` | Documentation. | + +## CLI Pattern + +```text +src/Cli/Commands/**/* +src/Cli/Handlers/**/* +``` + +Commands define syntax. Handlers do the work. + +## Project Layout + +Sibling projects live at the repository root: + +```text +Tools/ChangeTrace.Tools.csproj +Benchmarks/ChangeTrace.Benchmarks.csproj +Tests/ChangeTrace.Tests.csproj +``` + +Keep test files directly under `Tests/` unless a larger test area needs its own folder. + +## Workspace Timelines + +```text +src/CredentialTrace/Interfaces/IWorkspaceTimelineStorage.cs +src/CredentialTrace/Services/WorkspaceTimelineStorage.cs +``` + +Layout: + +```text +workspaces/{organization}/{workspace}/timelines/{repository}/ +``` diff --git a/Docs/Development/README.md b/Docs/Development/README.md new file mode 100644 index 0000000..5bfccf4 --- /dev/null +++ b/Docs/Development/README.md @@ -0,0 +1,27 @@ +# Development + +Docs for working on the codebase. + +## Files + +| File | Contents | +| --- | --- | +| [Setup](Setup.md) | Requirements and local run setup. | +| [Validation](Validation.md) | Build, tests, and quick checks. | +| [Project Structure](Project-Structure.md) | Where the main code lives. | +| [Debug](Debug.md) | `debdev` commands. | + +## Minimum Before PR + +```bash +dotnet build ChangeTrace.slnx +dotnet test Tests/ChangeTrace.Tests.csproj --no-build +``` + +The test project is top level sibling of `Tools/` and `Benchmarks/`. + +Or: + +```bash +task check +``` diff --git a/Docs/Development/Setup.md b/Docs/Development/Setup.md new file mode 100644 index 0000000..422fcf9 --- /dev/null +++ b/Docs/Development/Setup.md @@ -0,0 +1,42 @@ +# Setup + +## Requirements + +| Tool | Why | +| --- | --- | +| .NET 10 SDK | Build, tests, CLI. | +| Git | Repositories and workflow. | +| Task | Optional command shortcuts. | +| Graphical session | OpenTK player and render windows. | + +## Build + +```bash +dotnet restore ChangeTrace.slnx +dotnet build ChangeTrace.slnx +``` + +Or: + +```bash +task build +``` + +## Local CLI + +Docs use: + +```bash +./changetrace --help +``` + +In a dev build, this can point to the binary under `bin/Debug/net10.0/`. + +## Local Data + +```text +~/.changetrace/ +workspaces/{organization}/{workspace}/timelines/{repository}/ +``` + +Auth files are local to the user. Treat them as sensitive data. diff --git a/Docs/Development/Validation.md b/Docs/Development/Validation.md new file mode 100644 index 0000000..5fe9b4e --- /dev/null +++ b/Docs/Development/Validation.md @@ -0,0 +1,54 @@ +# Validation + +## Standard + +```bash +dotnet build ChangeTrace.slnx +dotnet test Tests/ChangeTrace.Tests.csproj --no-build +``` + +In restricted shells, `dotnet test` may need permission to create a local socket. + +The test project lives at: + +```text +Tests/ChangeTrace.Tests.csproj +``` + +## Task + +```bash +task build +task check +``` + +Useful tasks: + +| Task | Purpose | +| --- | --- | +| `task build` | Restore and build. | +| `task check` | Release build, tools, and asset validation. | +| `task benchmark` | Rendering benchmarks. | +| `task publish` | Publish a runtime. | + +## Manual Checks + +CLI: + +```bash +./changetrace --help +./changetrace ws ls +``` + +Workspace/export: + +```bash +./changetrace ws current +./changetrace ws tl +``` + +Player: + +```bash +./changetrace ws play -w -s +``` diff --git a/Docs/Guides/Cli/Auth.md b/Docs/Guides/Cli/Auth.md new file mode 100644 index 0000000..7ab1f8d --- /dev/null +++ b/Docs/Guides/Cli/Auth.md @@ -0,0 +1,17 @@ +# Auth + +```bash +./changetrace auth login github +./changetrace auth list +./changetrace auth logout github +``` + +## Commands + +| Command | Purpose | +| --- | --- | +| `auth login ` | Log in to a provider. | +| `auth list` | List saved sessions. | +| `auth logout [provider]` | Remove saved login data. | + +Sessions are local to the current user. Do not treat them as a keychain. diff --git a/Docs/Guides/Cli/Export.md b/Docs/Guides/Cli/Export.md new file mode 100644 index 0000000..adf45cd --- /dev/null +++ b/Docs/Guides/Cli/Export.md @@ -0,0 +1,44 @@ +# Export + +## `export` + +```bash +./changetrace export +./changetrace export -o ./file.gittrace +``` + +Examples: + +```bash +./changetrace export https://github.com/microsoft/msquic.git +./changetrace export ../local-repo +./changetrace export https://github.com/microsoft/WSL.git -o ./wsl.gittrace +``` + +Without `--output`, the file is stored in the active workspace: + +```text +workspaces/{organization}/{workspace}/timelines/{repository}/{timestamp}-{ulid}.gittrace +``` + +Timeline metadata is stored next to it: + +```text +*.gittrace.metadata.json +``` + +Key options: + +| Option | Meaning | +| --- | --- | +| `-o, --output ` | Write to a specific file. | +| `-r, --token ` | GitHub token. | +| `-v, --verbose` | More logs. | + +## `show` + +```bash +./changetrace show +``` + +For workspace timelines, `ws play` is usually easier. diff --git a/Docs/Guides/Cli/Organizations.md b/Docs/Guides/Cli/Organizations.md new file mode 100644 index 0000000..33faa36 --- /dev/null +++ b/Docs/Guides/Cli/Organizations.md @@ -0,0 +1,21 @@ +# Organizations + +`org` also has the full alias `organization`. + +```bash +./changetrace org create microsoft -p github +./changetrace org list +./changetrace org remove microsoft +``` + +## Commands + +| Command | Purpose | +| --- | --- | +| `org create -p ` | Create an organization. | +| `org list` | List organizations. | +| `org remove ` | Remove an organization. | + +Provider can be `github`. + +`org remove` deletes the organization profile. Use `-y` only when the target name is clear. diff --git a/Docs/Guides/Cli/README.md b/Docs/Guides/Cli/README.md new file mode 100644 index 0000000..fa37c4c --- /dev/null +++ b/Docs/Guides/Cli/README.md @@ -0,0 +1,56 @@ +# ChangeTrace CLI + +Examples use the local command `./changetrace`. + +## Quick Start + +```bash +./changetrace auth login github +./changetrace org create microsoft -p github +./changetrace ws create msquic --org microsoft +./changetrace ws use microsoft msquic +./changetrace export https://github.com/microsoft/msquic.git +./changetrace ws play +``` + +Interactive flow: + +```bash +./changetrace ws use +./changetrace ws play -w -s +``` + +## Sections + +| Section | File | +| --- | --- | +| Export and `.gittrace` | [Export](Export.md) | +| Workspaces | [Workspace](Workspace.md) | +| Organizations | [Organizations](Organizations.md) | +| Auth | [Auth](Auth.md) | +| Dev debug | [Debug](../../Development/Debug.md) | + +## Commands + +| Command | Alias | +| --- | --- | +| `export ` | | +| `show ` | | +| `workspace create ` | `ws create` | +| `workspace list` | `ws list`, `ws ls` | +| `workspace use [org] [name]` | `ws use`, `ws switch`, `ws select` | +| `workspace current` | `ws current`, `ws status`, `ws ctx` | +| `workspace timelines` | `ws timelines`, `ws timeline`, `ws tl` | +| `workspace play` | `ws play` | +| `workspace remove ` | `ws remove` | +| `org create ` | `organization create` | +| `org list` | `organization list` | +| `org remove ` | `organization remove` | +| `auth login ` | | +| `auth list` | | +| `auth logout [provider]` | | +| `debdev player ` | | +| `debdev render ` | | +| `debdev window ` | | + +`export` without `--output` writes to the active workspace. Set it with `ws use`. diff --git a/Docs/Guides/Cli/Workspace.md b/Docs/Guides/Cli/Workspace.md new file mode 100644 index 0000000..6ae4ce4 --- /dev/null +++ b/Docs/Guides/Cli/Workspace.md @@ -0,0 +1,48 @@ +# Workspace + +`workspace` has the short alias `ws`. + +```bash +./changetrace ws +``` + +## Flow + +```bash +./changetrace org create microsoft -p github +./changetrace ws create wsl --org microsoft +./changetrace ws use microsoft wsl +./changetrace export https://github.com/microsoft/WSL.git +./changetrace ws play +``` + +Interactive: + +```bash +./changetrace ws use +./changetrace ws play -w -s +``` + +## Commands + +| Command | Alias | Purpose | +| --- | --- | --- | +| `ws create --org ` | | Create a workspace. | +| `ws list` | `ws ls` | List workspaces. | +| `ws use [org] [name]` | `ws switch`, `ws select` | Set the active workspace. | +| `ws current` | `ws status`, `ws ctx` | Show the active workspace. | +| `ws timelines` | `ws timeline`, `ws tl` | List stored timelines. | +| `ws play` | | Open a timeline in the player. | +| `ws remove --org ` | | Remove a workspace profile. | + +## Useful Options + +| Command | Option | Meaning | +| --- | --- | --- | +| `ws list` | `--org ` | Filter by organization. | +| `ws play` | `-r, --repo ` | Pick a repo, for example `msquic` or `microsoft/wsl`. | +| `ws play` | `-s, --select` | Select a timeline from a list. | +| `ws play` | `-w, --workspace` | Select a workspace before playing. | +| `ws remove` | `-y, --yes` | Skip confirmation. | + +`ws play` needs a graphical session because it opens an OpenTK window. diff --git a/Docs/README.md b/Docs/README.md new file mode 100644 index 0000000..d4a9574 --- /dev/null +++ b/Docs/README.md @@ -0,0 +1,28 @@ +# ChangeTrace Docs + +Project documentation. + +## Start + +| Topic | File | +| --- | --- | +| CLI | [Guides / CLI](Guides/Cli/README.md) | +| Development | [Development](Development/README.md) | +| Project state | [Project State](Project-State.md) | +| Release/provenance | [Publish Attested](Publish-Attested.md) | + +## Layout + +```text +Docs/ + Guides/ User workflows. + Development/ Setup, validation, debug. +``` + +Add new docs where they fit the audience: + +| Type | Location | +| --- | --- | +| User guide | `Docs/Guides/` | +| Developer workflow | `Docs/Development/` | +| Architecture | `Docs/Architecture/` | diff --git a/readme.md b/readme.md index 19b65bf..baf7383 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # ChangeTrace - +
Language: C# @@ -19,29 +19,29 @@ ChangeTrace turns Git repositories into timeline files and lets you inspect them It reads repository history, branch activity, merges, and other Git events, then turns them into a timeline you can export, inspect, and replay locally. Instead of digging through raw logs or switching between tools, you get one app that combines analysis with an OpenGL visualization layer. > [!IMPORTANT] -> ChangeTrace keeps repository history, timeline data, and local auth in one place. +> ChangeTrace keeps repository history, timeline data, workspace profiles, and local auth in one place. > [!WARNING] -> This is an early experimental version. The core workflow exists, but the codebase is still being stabilized. +> This is an early experimental version. The core workflow exists, but the codebase and UX are still being stabilized. -Use it when you want a repeatable way to save repository history, check important moments, or use the same timeline in different views. It is made for local use, so the exported data, auth sessions, workspace state, and visual state stay on your machine. +Use it when you want a repeatable way to save repository history, check important moments, or use the same timeline in different views. It is made for local use, so exported data, auth sessions, workspace state, and visual state stay on your machine. The usual flow is simple: -- export a repository into a portable `.gittrace` file -- open that file later to inspect the timeline +- create or select a workspace +- export a repository into a portable `.gittrace` timeline +- open that timeline later from the workspace - keep auth and workspace data local instead of depending on a remote service ## Project State ChangeTrace is currently in `State 1`. -This phase is about making the core code stable, keeping export and inspection smooth, and making sure the current behavior stays predictable. +This phase is about making the core workflow stable, keeping export and inspection smooth, and improving the CLI/player experience around workspaces. -See [Project State](Docs/Project-State.md) for the full breakdown of the current phase and the next step. -For artifact provenance and verification, see [Publish Attested](Docs/Publish-Attested.md). +See [Project State](Docs/Project-State.md) for the full breakdown of the current phase and next steps. For artifact provenance and verification, see [Publish Attested](Docs/Publish-Attested.md). -## What it does +## What It Does ChangeTrace analyzes repository history and produces a structured timeline that can be used for: @@ -52,47 +52,89 @@ ChangeTrace analyzes repository history and produces a structured timeline that ## Commands -ChangeTrace exposes a small set of entry points that cover export, inspection, authentication, and workspace management. +ChangeTrace exposes entry points for export, inspection, authentication, organization profiles, workspace management, and playback. The main entry points are `export`, `show`, `auth`, `org`, and `workspace`. -Typical usage: +Typical workspace usage: ```bash -./changetrace export https://github.com/user/repo -o timeline.gittrace -./changetrace show timeline.gittrace ./changetrace auth login github -./changetrace auth list -./changetrace auth logout github -./changetrace org list --provider github -./changetrace workspace list --org acme +./changetrace org create microsoft -p github +./changetrace ws create msquic --org microsoft +./changetrace ws use microsoft msquic +./changetrace export https://github.com/microsoft/msquic.git +./changetrace ws play +``` + +Interactive workspace and timeline selection: + +```bash +./changetrace ws use +./changetrace ws play -w -s +``` + +Direct file export is still available when you want a specific output path: + +```bash +./changetrace export https://github.com/microsoft/WSL.git -o timeline.gittrace +./changetrace show timeline.gittrace +``` + +For local development, build the project and use `./changetrace` as the local command. It can point to the built binary under `bin/Debug/net10.0/`. + +```bash +dotnet build ChangeTrace.slnx +./changetrace --help +``` + +For the full command guide, see [CLI Guide](Docs/Guides/Cli/README.md). + +## Workspace Storage + +When `export` runs without `--output`, it writes to the active workspace: + +```text +workspaces/{organization}/{workspace}/timelines/{repository}/{timestamp}-{ulid}.gittrace ``` -For local development, replace `./changetrace` with `dotnet run --`. +Each workspace export also writes metadata next to the timeline: + +```text +*.gittrace.metadata.json +``` -## Project Structure +Use `ws current`, `ws timelines`, and `ws play` to inspect and replay workspace timelines. -The repository is split into a few clear layers: +## Documentation -- `src/Core` contains the event model, rules, and shared processing -- `src/Configuration` contains app settings, converters, and service discovery helpers -- `src/GIt` reads repositories, builds timelines, and enriches data -- `src/CredentialTrace` handles auth, sessions, and local storage -- `src/Rendering` builds the timeline scene and state -- `src/Graphics` owns the rendering runtime, shaders, and GPU helpers -- `src/Player` handles timeline playback flow and speed control -- `src/Cli` wires commands to handlers -- `Tools` contains repository utilities and asset maintenance scripts +| Topic | Link | +| --- | --- | +| Documentation index | [Docs](Docs/README.md) | +| CLI overview | [CLI Guide](Docs/Guides/Cli/README.md) | +| Export | [Export](Docs/Guides/Cli/Export.md) | +| Workspaces | [Workspace](Docs/Guides/Cli/Workspace.md) | +| Organizations | [Organizations](Docs/Guides/Cli/Organizations.md) | +| Auth | [Auth](Docs/Guides/Cli/Auth.md) | +| Development | [Development](Docs/Development/README.md) | +| Project structure | [Project Structure](Docs/Development/Project-Structure.md) | +| Validation | [Validation](Docs/Development/Validation.md) | ## Local Data ChangeTrace stores local auth data under the user profile: -- On Unix like systems the data lives under `~/.changetrace/`. +- On Unix-like systems the data lives under `~/.changetrace/`. - On Windows it uses the equivalent user profile directory. - `auth.json` contains session metadata and encrypted tokens. - `auth.key` contains the local key used by the token store. +Workspace timelines are stored under the app workspace storage: + +```text +workspaces/{organization}/{workspace}/timelines/{repository}/ +``` + This protects against accidental plaintext exposure in `auth.json`, but it is not a full operating-system keychain. A process running as the same user can still read both files. ## License diff --git a/src/Cli/Commands/ExportCommand.cs b/src/Cli/Commands/ExportCommand.cs index 4438bfc..31ddef8 100644 --- a/src/Cli/Commands/ExportCommand.cs +++ b/src/Cli/Commands/ExportCommand.cs @@ -8,13 +8,13 @@ namespace ChangeTrace.Cli.Commands; /// -/// Represents 'export' CLI command that exports a repository timeline to JSON file. +/// Represents 'export' CLI command that exports a repository timeline to a .gittrace file. /// /// /// /// Implements to define the command structure and associate a handler. /// Registers itself as a singleton via . -/// Defines arguments and options for specifying the repository, output file, GitHub token, and verbosity. +/// Defines arguments and options for specifying the repository, optional output file, GitHub token, and verbosity. /// The actual execution logic is handled by . /// /// @@ -36,7 +36,10 @@ public Command Build() var cmd = new Command("export", "Export repository timeline"); var repoArg = new Argument("repository") { Description = "Local path or HTTPS URL to Git repository" }; - var outputOpt = new Option("--output", "-o") { Description = "Output JSON file path", DefaultValueFactory = _ => "timeline.gittrace" }; + var outputOpt = new Option("--output", "-o") + { + Description = "Explicit output .gittrace path. When omitted, export is saved under the active workspace." + }; var tokenOpt = new Option("--token", "-r") { Description = "GitHub personal access token" }; var verboseOpt = new Option("--verbose", "-v") { Description = "Enable verbose logging" }; @@ -47,4 +50,4 @@ public Command Build() return cmd; } -} \ No newline at end of file +} diff --git a/src/Cli/Commands/Profiles/Organizations/OrgCommand.cs b/src/Cli/Commands/Profiles/Organizations/OrgCommand.cs index 5fe2f52..89b3c41 100644 --- a/src/Cli/Commands/Profiles/Organizations/OrgCommand.cs +++ b/src/Cli/Commands/Profiles/Organizations/OrgCommand.cs @@ -41,5 +41,9 @@ internal sealed class OrgCommand : ICliCommand /// /// Configured for organization subcommands. public Command Build() - => new("org", "Organization management"); -} \ No newline at end of file + { + var cmd = new Command("org", "Organization management"); + cmd.Aliases.Add("organization"); + return cmd; + } +} diff --git a/src/Cli/Commands/Profiles/Workspaces/WorkCommand.cs b/src/Cli/Commands/Profiles/Workspaces/WorkCommand.cs index 0eceeb2..b662331 100644 --- a/src/Cli/Commands/Profiles/Workspaces/WorkCommand.cs +++ b/src/Cli/Commands/Profiles/Workspaces/WorkCommand.cs @@ -41,5 +41,9 @@ internal class WorkCommand : ICliCommand /// /// Configured for workspace subcommands. public Command Build() - => new("workspace", "Workspace management"); -} \ No newline at end of file + { + var cmd = new Command("workspace", "Workspace management"); + cmd.Aliases.Add("ws"); + return cmd; + } +} diff --git a/src/Cli/Commands/Profiles/Workspaces/WorkCurrentCommand.cs b/src/Cli/Commands/Profiles/Workspaces/WorkCurrentCommand.cs new file mode 100644 index 0000000..0154359 --- /dev/null +++ b/src/Cli/Commands/Profiles/Workspaces/WorkCurrentCommand.cs @@ -0,0 +1,23 @@ +using System.CommandLine; +using ChangeTrace.Cli.Handlers.Profiles.Workspaces; +using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Configuration.Discovery; +using Microsoft.Extensions.DependencyInjection; + +namespace ChangeTrace.Cli.Commands.Profiles.Workspaces; + +[AutoRegister(ServiceLifetime.Singleton)] +internal sealed class WorkCurrentCommand : ICliCommand +{ + public Type HandlerType => typeof(WorkCurrentCommandHandler); + + public Type Parent => typeof(WorkCommand); + + public Command Build() + { + var cmd = new Command("current", "Show active workspace"); + cmd.Aliases.Add("status"); + cmd.Aliases.Add("ctx"); + return cmd; + } +} diff --git a/src/Cli/Commands/Profiles/Workspaces/WorkListCommand.cs b/src/Cli/Commands/Profiles/Workspaces/WorkListCommand.cs index 781d9f3..a2ac635 100644 --- a/src/Cli/Commands/Profiles/Workspaces/WorkListCommand.cs +++ b/src/Cli/Commands/Profiles/Workspaces/WorkListCommand.cs @@ -35,9 +35,13 @@ internal sealed class WorkListCommand : ICliCommand /// Builds representing workspace list command. /// /// Configured with optional filtering options. - public Command Build() => - new("list", "List workspaces") + public Command Build() + { + var cmd = new Command("list", "List workspaces") { - new Option("--org") { Description = "Organization name" } + new Option("--org", "-o") { Description = "Organization name. When omitted, all workspaces are listed." } }; -} \ No newline at end of file + cmd.Aliases.Add("ls"); + return cmd; + } +} diff --git a/src/Cli/Commands/Profiles/Workspaces/WorkPlayCommand.cs b/src/Cli/Commands/Profiles/Workspaces/WorkPlayCommand.cs new file mode 100644 index 0000000..8af212f --- /dev/null +++ b/src/Cli/Commands/Profiles/Workspaces/WorkPlayCommand.cs @@ -0,0 +1,32 @@ +using System.CommandLine; +using ChangeTrace.Cli.Handlers.Profiles.Workspaces; +using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Configuration.Discovery; +using Microsoft.Extensions.DependencyInjection; + +namespace ChangeTrace.Cli.Commands.Profiles.Workspaces; + +[AutoRegister(ServiceLifetime.Singleton)] +internal sealed class WorkPlayCommand : ICliCommand +{ + public Type HandlerType => typeof(WorkPlayCommandHandler); + + public Type Parent => typeof(WorkCommand); + + public Command Build() + => new("play", "Play a timeline from the active workspace") + { + new Option("--repo", "-r") + { + Description = "Repository filter, for example microsoft/msquic or msquic. Defaults to the newest timeline." + }, + new Option("--select", "-s") + { + Description = "Prompt for timeline selection instead of opening the newest match." + }, + new Option("--workspace", "-w") + { + Description = "Prompt for workspace before playing." + } + }; +} diff --git a/src/Cli/Commands/Profiles/Workspaces/WorkTimelinesCommand.cs b/src/Cli/Commands/Profiles/Workspaces/WorkTimelinesCommand.cs new file mode 100644 index 0000000..8b13f7c --- /dev/null +++ b/src/Cli/Commands/Profiles/Workspaces/WorkTimelinesCommand.cs @@ -0,0 +1,23 @@ +using System.CommandLine; +using ChangeTrace.Cli.Handlers.Profiles.Workspaces; +using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Configuration.Discovery; +using Microsoft.Extensions.DependencyInjection; + +namespace ChangeTrace.Cli.Commands.Profiles.Workspaces; + +[AutoRegister(ServiceLifetime.Singleton)] +internal sealed class WorkTimelinesCommand : ICliCommand +{ + public Type HandlerType => typeof(WorkTimelinesCommandHandler); + + public Type Parent => typeof(WorkCommand); + + public Command Build() + { + var cmd = new Command("timelines", "List timeline files stored for the active workspace"); + cmd.Aliases.Add("timeline"); + cmd.Aliases.Add("tl"); + return cmd; + } +} diff --git a/src/Cli/Commands/Profiles/Workspaces/WorkUseCommand.cs b/src/Cli/Commands/Profiles/Workspaces/WorkUseCommand.cs index 62ec9bd..e30f8c0 100644 --- a/src/Cli/Commands/Profiles/Workspaces/WorkUseCommand.cs +++ b/src/Cli/Commands/Profiles/Workspaces/WorkUseCommand.cs @@ -14,8 +14,8 @@ namespace ChangeTrace.Cli.Commands.Profiles.Workspaces; /// /// Child command of . /// Delegates execution to . -/// Requires workspace name argument. -/// Optionally specifies the organization using --org option. +/// Accepts optional organization and workspace arguments. +/// Prompts for missing values in interactive terminals. /// Registered automatically as singleton via . /// /// @@ -36,9 +36,22 @@ internal sealed class WorkUseCommand : ICliCommand /// Builds the instance representing the workspace use command. /// /// A configured with required arguments and optional organization filter. - public Command Build() => new("use", "Select workspace to play") + public Command Build() { - new Argument("org") { Description = "Organization name" }, - new Argument("name") { Description = "Workspace name" } - }; -} \ No newline at end of file + var cmd = new Command("use", "Select active workspace"); + cmd.Aliases.Add("switch"); + cmd.Aliases.Add("select"); + cmd.Arguments.Add(new Argument("org") + { + Description = "Organization name", + Arity = ArgumentArity.ZeroOrOne + }); + cmd.Arguments.Add(new Argument("name") + { + Description = "Workspace name", + Arity = ArgumentArity.ZeroOrOne + }); + + return cmd; + } +} diff --git a/src/Cli/Commands/ShowTimelineCommand.cs b/src/Cli/Commands/ShowTimelineCommand.cs index b516fb0..cbca1bf 100644 --- a/src/Cli/Commands/ShowTimelineCommand.cs +++ b/src/Cli/Commands/ShowTimelineCommand.cs @@ -1,7 +1,6 @@ using System.CommandLine; using ChangeTrace.Cli.Handlers; using ChangeTrace.Cli.Interfaces; -using ChangeTrace.Configuration; using ChangeTrace.Configuration.Discovery; using Microsoft.Extensions.DependencyInjection; @@ -27,7 +26,7 @@ internal sealed class ShowTimelineCommand : ICliCommand public Type HandlerType => typeof(ShowTimelineCommandHandler); public Type? Parent => null; - + /// /// Builds the instance representing the 'show' CLI command. /// @@ -40,4 +39,4 @@ public Command Build() cmd.Arguments.Add(fileArg); return cmd; } -} \ No newline at end of file +} diff --git a/src/Cli/Handlers/ExportCommandHandler.cs b/src/Cli/Handlers/ExportCommandHandler.cs index 1b28ecb..6412146 100644 --- a/src/Cli/Handlers/ExportCommandHandler.cs +++ b/src/Cli/Handlers/ExportCommandHandler.cs @@ -12,34 +12,46 @@ namespace ChangeTrace.Cli.Handlers; /// -/// CLI handler responsible for exporting a Git repository into a ChangeTrace timeline file. +/// Exports Git repository into ChangeTrace timeline file. /// -/// -/// -/// Resolves authentication token from CLI option or stored session. -/// Detects repository provider automatically. -/// Invokes to perform export pipeline. -/// Displays progress and result using Spectre.Console UI. -/// -/// [AutoRegister(ServiceLifetime.Transient, typeof(ExportCommandHandler))] internal sealed class ExportCommandHandler( IAuthService sessionAuthStore, + IWorkspaceContext workspaceContext, + IWorkspaceTimelineStorage workspaceTimelineStorage, IRepositoryExporter exporter) : ICliHandler { /// - /// Executes the export command. + /// Runs the repository export command. /// - /// Parsed CLI arguments. - /// Cancellation token. - /// Task representing asynchronous command execution. public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) { var repo = parseResult.GetValue("repository")!; - var output = parseResult.GetValue("--output")!; + var explicitOutput = parseResult.GetValue("--output"); var token = parseResult.GetValue("--token"); var verbose = parseResult.GetValue("--verbose"); - + var exportedAt = DateTimeOffset.UtcNow; + + var output = explicitOutput; + var workspace = workspaceContext.Current; + + if (string.IsNullOrWhiteSpace(output)) + { + if (workspace == null) + { + AnsiConsole.MarkupLine( + "[red]Failed:[/] no active workspace selected. Use [yellow]workspace use [/] or pass [yellow]--output/-o[/]."); + return; + } + + output = await workspaceTimelineStorage.CreateTimelinePathAsync( + workspace, + repo, + exportedAt, + Ulid.NewUlid().ToString(), + ct); + } + if (string.IsNullOrWhiteSpace(token)) { var provider = ProviderUrlHelper.DetectProvider(repo); @@ -71,8 +83,15 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) return await exporter.ExportAndSaveAsync(repo, output, options, progress, ct); }); - AnsiConsole.MarkupLine(result.IsSuccess - ? $"[green]Exported successfully to {output}[/]" - : $"[red]Failed: {result.Error}[/]"); + if (result.IsFailure) + { + AnsiConsole.MarkupLine($"[red]Failed:[/] {Markup.Escape(result.Error ?? "Unknown error")}"); + return; + } + + if (string.IsNullOrWhiteSpace(explicitOutput) && workspace != null) + await workspaceTimelineStorage.SaveMetadataAsync(output, workspace, repo, exportedAt, ct); + + AnsiConsole.MarkupLine($"[green]Exported successfully to[/] {Markup.Escape(output)}"); } } \ No newline at end of file diff --git a/src/Cli/Handlers/Profiles/Workspaces/WorkCurrentCommandHandler.cs b/src/Cli/Handlers/Profiles/Workspaces/WorkCurrentCommandHandler.cs new file mode 100644 index 0000000..61efb02 --- /dev/null +++ b/src/Cli/Handlers/Profiles/Workspaces/WorkCurrentCommandHandler.cs @@ -0,0 +1,47 @@ +using System.CommandLine; +using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Configuration.Discovery; +using ChangeTrace.CredentialTrace.Interfaces; +using ChangeTrace.CredentialTrace.Profiles; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; + +namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces; + +/// +/// Displays information about the active workspace. +/// +[AutoRegister(ServiceLifetime.Transient, typeof(WorkCurrentCommandHandler))] +internal sealed class WorkCurrentCommandHandler( + IWorkspaceContext workspaceContext, + IWorkspaceTimelineStorage timelineStorage, + IProfileStore orgStore) : ICliHandler +{ + /// + /// Shows the currently selected workspace and timeline count. + /// + public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) + { + var workspace = workspaceContext.Current; + if (workspace == null) + { + AnsiConsole.MarkupLine("[yellow]No active workspace selected.[/]"); + return; + } + + var organization = await orgStore.GetAsync(workspace.OrganizationId, ct); + var timelines = await timelineStorage.ListTimelinesAsync(workspace, ct); + + var panel = new Panel( + $"[bold]Organization:[/] {organization?.Name ?? workspace.OrganizationId.ToString()}\n" + + $"[bold]Workspace:[/] {workspace.Name}\n" + + $"[bold]Workspace ID:[/] {workspace.Id}\n" + + $"[bold]Timelines:[/] {timelines.Count}") + .Header("[green]Active Workspace[/]") + .Border(BoxBorder.Rounded) + .BorderStyle(Color.Green) + .Padding(new Padding(1)); + + AnsiConsole.Write(panel); + } +} diff --git a/src/Cli/Handlers/Profiles/Workspaces/WorkListCommandHandler.cs b/src/Cli/Handlers/Profiles/Workspaces/WorkListCommandHandler.cs index 82498ba..dd2f85b 100644 --- a/src/Cli/Handlers/Profiles/Workspaces/WorkListCommandHandler.cs +++ b/src/Cli/Handlers/Profiles/Workspaces/WorkListCommandHandler.cs @@ -9,56 +9,69 @@ namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces; /// -/// Handler for 'workspace list' CLI command. +/// Lists workspaces for an organization or all organizations. /// -/// -/// -/// Implements to list workspaces for an organization. -/// Uses to query workspaces by organization name. -/// Outputs a formatted table with ID, name, and creation date. -/// Displays a warning if no workspaces are found. -/// -/// [AutoRegister(ServiceLifetime.Transient, typeof(WorkListCommandHandler))] -internal sealed class WorkListCommandHandler(IWorkspaceStore store) : ICliHandler +internal sealed class WorkListCommandHandler( + IWorkspaceStore store, + IProfileStore orgStore) : ICliHandler { /// - /// Executes 'workspace list' command asynchronously. + /// Displays available workspaces. /// - /// Parsed CLI arguments. - /// Cancellation token. public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) { - var orgName = parseResult.GetValue("--org"); + var orgName = parseResult.GetValue("--org"); if (string.IsNullOrWhiteSpace(orgName)) { - AnsiConsole.MarkupLine("[red]Error:[/] --org is required."); + await DisplayAllWorkspacesAsync(ct); return; } - await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green")) - .StartAsync("Loading workspaces...", async ctx => - { - var workspaces = await store.GetByNameOrganization(orgName, ct); - var list = workspaces.ToList(); + var workspaces = await store.GetByNameOrganization(orgName, ct); + var list = workspaces.ToList(); - if (!list.Any()) - { - AnsiConsole.MarkupLine("[yellow] No workspaces found for this organization.[/]"); - return; - } + if (!list.Any()) + { + AnsiConsole.MarkupLine("[yellow]No workspaces found for this organization.[/]"); + return; + } + + DisplayWorkspacesPanel(list, orgName); + } + + /// + /// Displays workspaces from all organizations. + /// + private async Task DisplayAllWorkspacesAsync(CancellationToken ct) + { + var organizations = (await orgStore.GetAllAsync(ct)) + .ToDictionary(org => org.Id, org => org.Name); - await Task.Delay(300, ct); + var workspaces = (await store.GetAllAsync(ct)) + .OrderBy(workspace => + organizations.GetValueOrDefault( + workspace.OrganizationId, + workspace.OrganizationId.ToString())) + .ThenBy(workspace => workspace.Name) + .ToList(); - DisplayWorkspacesPanel(list, orgName); - }); + if (workspaces.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No workspaces found.[/]"); + return; + } + + DisplayAllWorkspacesPanel(workspaces, organizations); } - - private static void DisplayWorkspacesPanel(IReadOnlyList workspaces, string organizationName) + /// + /// Renders workspaces for a single organization. + /// + private static void DisplayWorkspacesPanel( + IReadOnlyList workspaces, + string organizationName) { var table = new Table() .Border(TableBorder.Rounded) @@ -68,7 +81,10 @@ private static void DisplayWorkspacesPanel(IReadOnlyList works foreach (var ws in workspaces) { - table.AddRow(ws.Id.ToString(), ws.Name, ws.CreatedAt.ToString("u")); + table.AddRow( + ws.Id.ToString(), + ws.Name, + ws.CreatedAt.ToString("u")); } var panel = new Panel(table) @@ -79,4 +95,36 @@ private static void DisplayWorkspacesPanel(IReadOnlyList works AnsiConsole.Write(panel); } + + /// + /// Renders workspaces grouped by organization. + /// + private static void DisplayAllWorkspacesPanel( + IReadOnlyList workspaces, + IReadOnlyDictionary organizations) + { + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("Organization") + .AddColumn("Workspace") + .AddColumn("Created At"); + + foreach (var ws in workspaces) + { + table.AddRow( + organizations.GetValueOrDefault( + ws.OrganizationId, + ws.OrganizationId.ToString()), + ws.Name, + ws.CreatedAt.ToString("u")); + } + + var panel = new Panel(table) + .Header("[green]Workspaces[/]") + .Border(BoxBorder.Rounded) + .BorderStyle(Color.Green) + .Padding(new Padding(1)); + + AnsiConsole.Write(panel); + } } \ No newline at end of file diff --git a/src/Cli/Handlers/Profiles/Workspaces/WorkPlayCommandHandler.cs b/src/Cli/Handlers/Profiles/Workspaces/WorkPlayCommandHandler.cs new file mode 100644 index 0000000..ad6f4b8 --- /dev/null +++ b/src/Cli/Handlers/Profiles/Workspaces/WorkPlayCommandHandler.cs @@ -0,0 +1,222 @@ +using System.CommandLine; +using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Configuration.Discovery; +using ChangeTrace.Core.Diagnostics; +using ChangeTrace.CredentialTrace.Interfaces; +using ChangeTrace.CredentialTrace.Profiles; +using ChangeTrace.GIt.Interfaces; +using ChangeTrace.Graphics.Window; +using ChangeTrace.Player.Factory; +using ChangeTrace.Rendering.Factory; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; + +namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces; + +/// +/// Plays timelines stored in a workspace. +/// +[AutoRegister(ServiceLifetime.Transient, typeof(WorkPlayCommandHandler))] +internal sealed class WorkPlayCommandHandler( + IWorkspaceContext workspaceContext, + IWorkspaceTimelineStorage timelineStorage, + IProfileStore orgStore, + IProfileStore workspaceStore, + ITimelineSerializer serializer, + ITimelinePlayerFactory playerFactory, + IRenderSystemFactory renderFactory, + IDiagnosticsProvider diagnostics) : ICliHandler +{ + /// + /// Opens and plays a selected timeline. + /// + public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) + { + var promptWorkspace = parseResult.GetValue("--workspace"); + var workspace = promptWorkspace + ? await SelectWorkspaceAsync(ct) + : workspaceContext.Current; + + if (workspace == null) + { + if (!AnsiConsole.Profile.Capabilities.Interactive) + { + AnsiConsole.MarkupLine("[red]No active workspace selected.[/]"); + return; + } + + workspace = await SelectWorkspaceAsync(ct); + if (workspace == null) + return; + } + + var repoFilter = parseResult.GetValue("--repo"); + var select = parseResult.GetValue("--select"); + var timelines = await timelineStorage.ListTimelinesAsync(workspace, ct); + var selected = SelectTimeline(timelines, repoFilter, select); + + if (selected == null) + { + var suffix = string.IsNullOrWhiteSpace(repoFilter) + ? string.Empty + : $" matching '{Markup.Escape(repoFilter)}'"; + + AnsiConsole.MarkupLine($"[yellow]No timelines found for the active workspace{suffix}.[/]"); + return; + } + + await PlayTimelineAsync(selected, ct); + } + + /// + /// Prompts for workspace selection. + /// + private async Task SelectWorkspaceAsync(CancellationToken ct) + { + var organizations = (await orgStore.GetAllAsync(ct)) + .OrderBy(org => org.Name) + .ToList(); + + if (organizations.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No organizations found.[/]"); + return null; + } + + if (!AnsiConsole.Profile.Capabilities.Interactive) + { + AnsiConsole.MarkupLine("[red]Workspace selection requires an interactive terminal.[/]"); + return null; + } + + var organization = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select organization") + .PageSize(8) + .UseConverter(org => org.Name) + .AddChoices(organizations)); + + var workspaces = (await workspaceStore.GetAllAsync(ct)) + .Where(workspace => workspace.OrganizationId == organization.Id) + .OrderBy(workspace => workspace.Name) + .ToList(); + + if (workspaces.Count == 0) + { + AnsiConsole.MarkupLine($"[yellow]No workspaces found in organization '{Markup.Escape(organization.Name)}'.[/]"); + return null; + } + + var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"Select workspace in {organization.Name}") + .PageSize(8) + .UseConverter(workspace => workspace.Name) + .AddChoices(workspaces)); + + await workspaceContext.SetCurrentAsync(selected, ct); + return selected; + } + + /// + /// Loads and plays a timeline. + /// + private async Task PlayTimelineAsync(WorkspaceTimelineFile selected, CancellationToken ct) + { + if (!File.Exists(selected.Path)) + { + AnsiConsole.MarkupLine($"[red]Timeline file not found:[/] {Markup.Escape(selected.Path)}"); + return; + } + + AnsiConsole.MarkupLine($"[green]Playing[/] {Markup.Escape(selected.Path)}"); + + var data = await File.ReadAllBytesAsync(selected.Path, ct); + var timeline = await serializer.DeserializeAsync(data, ct); + var player = playerFactory.Create(timeline, initialSpeed: 1.5, acceleration: 2.5); + + using var debugWindow = new DebugWindow(diagnostics); + using var window = new PlayerWindow(timeline, playerFactory, renderFactory, diagnostics); + window.SetDebugWindow(debugWindow); + window.Run(); + } + + /// + /// Selects a timeline from available candidates. + /// + private static WorkspaceTimelineFile? SelectTimeline( + IReadOnlyList timelines, + string? repoFilter, + bool prompt) + { + var candidates = string.IsNullOrWhiteSpace(repoFilter) + ? timelines + : timelines.Where(timeline => MatchesRepository(timeline, repoFilter)).ToList(); + + var ordered = candidates + .OrderByDescending(timeline => timeline.Metadata?.ExportedAtUtc ?? timeline.LastModifiedUtc) + .ToList(); + + if (ordered.Count == 0) + return null; + + if (!prompt || ordered.Count == 1) + return ordered[0]; + + if (!AnsiConsole.Profile.Capabilities.Interactive) + { + AnsiConsole.MarkupLine("[yellow]Timeline selection requires an interactive terminal; opening the newest match.[/]"); + return ordered[0]; + } + + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select timeline") + .PageSize(10) + .UseConverter(FormatTimelineChoice) + .AddChoices(ordered)); + } + + /// + /// Formats a timeline entry for interactive selection. + /// + private static string FormatTimelineChoice(WorkspaceTimelineFile timeline) + { + var metadata = timeline.Metadata; + var repository = metadata?.RepositoryOwner is null + ? metadata?.RepositoryName ?? "unknown" + : $"{metadata.RepositoryOwner}/{metadata.RepositoryName}"; + var exported = metadata?.ExportedAtUtc.ToString("u") ?? timeline.LastModifiedUtc.ToString("u"); + + return $"{repository} | {exported} | {Path.GetFileName(timeline.Path)}"; + } + + /// + /// Determines whether a timeline matches the repository filter. + /// + private static bool MatchesRepository(WorkspaceTimelineFile timeline, string repoFilter) + { + var normalizedFilter = Normalize(repoFilter); + var metadata = timeline.Metadata; + + if (metadata != null) + { + if (!string.IsNullOrWhiteSpace(metadata.RepositoryName) && + Normalize(metadata.RepositoryName) == normalizedFilter) + return true; + + if (!string.IsNullOrWhiteSpace(metadata.RepositoryOwner) && + !string.IsNullOrWhiteSpace(metadata.RepositoryName) && + Normalize($"{metadata.RepositoryOwner}/{metadata.RepositoryName}") == normalizedFilter) + return true; + } + + return Normalize(timeline.Path).Contains(normalizedFilter, StringComparison.Ordinal); + } + + /// + /// Normalizes repository identifiers for comparison. + /// + private static string Normalize(string value) + => value.Trim().Replace('\\', '/').ToLowerInvariant(); +} \ No newline at end of file diff --git a/src/Cli/Handlers/Profiles/Workspaces/WorkTimelinesCommandHandler.cs b/src/Cli/Handlers/Profiles/Workspaces/WorkTimelinesCommandHandler.cs new file mode 100644 index 0000000..5ecd37d --- /dev/null +++ b/src/Cli/Handlers/Profiles/Workspaces/WorkTimelinesCommandHandler.cs @@ -0,0 +1,95 @@ +using System.CommandLine; +using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Configuration.Discovery; +using ChangeTrace.CredentialTrace.Interfaces; +using ChangeTrace.CredentialTrace.Profiles; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; + +namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces; + +/// +/// Lists timelines stored in the active workspace. +/// +[AutoRegister(ServiceLifetime.Transient, typeof(WorkTimelinesCommandHandler))] +internal sealed class WorkTimelinesCommandHandler( + IWorkspaceContext workspaceContext, + IWorkspaceTimelineStorage timelineStorage) : ICliHandler +{ + /// + /// Displays timelines for the active workspace. + /// + public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) + { + var workspace = workspaceContext.Current; + if (workspace == null) + { + AnsiConsole.MarkupLine("[red]No active workspace selected.[/]"); + return; + } + + var timelines = await timelineStorage.ListTimelinesAsync(workspace, ct); + if (timelines.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No timelines found for the active workspace.[/]"); + return; + } + + DisplayTimelines(workspace, timelines); + } + + /// + /// Renders timeline files for a workspace. + /// + private static void DisplayTimelines( + WorkspaceProfile workspace, + IReadOnlyList timelines) + { + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("Repository") + .AddColumn("Exported") + .AddColumn("Size") + .AddColumn("Path"); + + foreach (var timeline in timelines) + { + var metadata = timeline.Metadata; + var repository = metadata?.RepositoryOwner is null + ? metadata?.RepositoryName ?? "unknown" + : $"{metadata.RepositoryOwner}/{metadata.RepositoryName}"; + + var exportedAt = metadata?.ExportedAtUtc.ToString("u") + ?? timeline.LastModifiedUtc.ToString("u"); + + table.AddRow( + Markup.Escape(repository), + exportedAt, + FormatSize(timeline.SizeBytes), + Markup.Escape(timeline.Path)); + } + + var panel = new Panel(table) + .Header($"[green]Timelines for '{Markup.Escape(workspace.Name)}'[/]") + .Border(BoxBorder.Rounded) + .BorderStyle(Color.Green) + .Padding(new Padding(1)); + + AnsiConsole.Write(panel); + } + + /// + /// Formats byte count into a readable file size. + /// + private static string FormatSize(long bytes) + { + if (bytes < 1024) + return $"{bytes} B"; + + var kb = bytes / 1024d; + if (kb < 1024) + return $"{kb:0.0} KB"; + + return $"{kb / 1024d:0.0} MB"; + } +} diff --git a/src/Cli/Handlers/Profiles/Workspaces/WorkUseCommandHandler.cs b/src/Cli/Handlers/Profiles/Workspaces/WorkUseCommandHandler.cs index daabd1e..71eeab8 100644 --- a/src/Cli/Handlers/Profiles/Workspaces/WorkUseCommandHandler.cs +++ b/src/Cli/Handlers/Profiles/Workspaces/WorkUseCommandHandler.cs @@ -3,23 +3,14 @@ using ChangeTrace.Configuration.Discovery; using ChangeTrace.CredentialTrace.Interfaces; using ChangeTrace.CredentialTrace.Profiles; -using ChangeTrace.CredentialTrace.Services; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces; /// -/// Handler for 'workspace use' CLI command that sets a workspace as the current active workspace. +/// Sets the active workspace. /// -/// -/// -/// Implements to update the current in . -/// Validates that the organization exists using . -/// Validates that the workspace exists in the given organization using . -/// Displays a confirmation panel with workspace and organization details on success. -/// -/// [AutoRegister(ServiceLifetime.Transient, typeof(WorkUseCommandHandler))] internal sealed class WorkUseCommandHandler( IProfileStore orgStore, @@ -27,18 +18,41 @@ internal sealed class WorkUseCommandHandler( IWorkspaceContext context) : ICliHandler { /// - /// Executes 'workspace use' command asynchronously. + /// Selects and activates a workspace. /// - /// Parsed CLI arguments. - /// Cancellation token. public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) { - var orgName = parseResult.GetValue("org"); - var wsName = parseResult.GetValue("name"); + var orgName = parseResult.GetValue("org"); + var wsName = parseResult.GetValue("name"); - if (string.IsNullOrWhiteSpace(orgName) || string.IsNullOrWhiteSpace(wsName)) + if (string.IsNullOrWhiteSpace(orgName)) { - AnsiConsole.MarkupLine("[red]Organization name and workspace name are required[/]"); + var organizations = (await orgStore.GetAllAsync(ct)) + .OrderBy(org => org.Name) + .ToList(); + + if (organizations.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No organizations found.[/]"); + return; + } + + if (!AnsiConsole.Profile.Capabilities.Interactive) + { + AnsiConsole.MarkupLine("[red]Organization name is required in non-interactive terminals.[/]"); + return; + } + + orgName = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select organization") + .PageSize(8) + .AddChoices(organizations.Select(org => org.Name))); + } + + if (string.IsNullOrWhiteSpace(orgName)) + { + AnsiConsole.MarkupLine("[red]Organization name is required[/]"); return; } @@ -49,14 +63,40 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) return; } - var allWorkspaces = await workspaceStore.GetAllAsync(ct); - var workspace = allWorkspaces.FirstOrDefault(w => - w.OrganizationId == org.Id && - w.Name.Equals(wsName, StringComparison.OrdinalIgnoreCase)); + var allWorkspaces = (await workspaceStore.GetAllAsync(ct)) + .Where(workspace => workspace.OrganizationId == org.Id) + .OrderBy(workspace => workspace.Name) + .ToList(); + + if (string.IsNullOrWhiteSpace(wsName)) + { + if (allWorkspaces.Count == 0) + { + AnsiConsole.MarkupLine( + $"[yellow]No workspaces found in organization '{Markup.Escape(orgName)}'.[/]"); + return; + } + + if (!AnsiConsole.Profile.Capabilities.Interactive) + { + AnsiConsole.MarkupLine("[red]Workspace name is required in non-interactive terminals.[/]"); + return; + } + + wsName = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"Select workspace in {org.Name}") + .PageSize(8) + .AddChoices(allWorkspaces.Select(workspace => workspace.Name))); + } + + var workspace = allWorkspaces.FirstOrDefault(workspace => + workspace.Name.Equals(wsName, StringComparison.OrdinalIgnoreCase)); if (workspace == null) { - AnsiConsole.MarkupLine($"[red]Workspace '{wsName}' not found in organization '{orgName}'[/]"); + AnsiConsole.MarkupLine( + $"[red]Workspace '{wsName}' not found in organization '{orgName}'[/]"); return; } @@ -65,11 +105,11 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) } /// - /// Displays confirmation panel indicating the workspace is now active. + /// Displays the activated workspace. /// - /// The set as active. - /// The parent . - private static void DisplayConfirmation(WorkspaceProfile workspace, OrganizationProfile organization) + private static void DisplayConfirmation( + WorkspaceProfile workspace, + OrganizationProfile organization) { var panel = new Panel( $"[bold]Workspace:[/] {workspace.Name}\n" + diff --git a/src/Cli/Handlers/ShowTimelineCommandHandler.cs b/src/Cli/Handlers/ShowTimelineCommandHandler.cs index 734da58..ea1df37 100644 --- a/src/Cli/Handlers/ShowTimelineCommandHandler.cs +++ b/src/Cli/Handlers/ShowTimelineCommandHandler.cs @@ -1,22 +1,15 @@ using System.CommandLine; using System.Text.Json; -using System.Text.Json.Serialization.Metadata; using ChangeTrace.Cli.Interfaces; using ChangeTrace.Configuration.Discovery; -using ChangeTrace.Core.Models; -using ChangeTrace.Core.Services; +using ChangeTrace.Core.Diagnostics; using ChangeTrace.Core.Specifications.Queries; using ChangeTrace.Core.Specifications.Queries.Commits; -using ChangeTrace.GIt.Dto; using ChangeTrace.GIt.Interfaces; +using ChangeTrace.Graphics.Window; using ChangeTrace.Player.Factory; -using ChangeTrace.Rendering; using ChangeTrace.Rendering.Factory; -using MessagePack; -using MessagePack.Resolvers; using Microsoft.Extensions.DependencyInjection; -using ChangeTrace.Core.Diagnostics; -using ChangeTrace.Graphics.Window; namespace ChangeTrace.Cli.Handlers; @@ -31,37 +24,27 @@ internal sealed class ShowTimelineCommandHandler( IRenderSystemFactory renderFactory, IDiagnosticsProvider diagnostics): ICliHandler { - public Task HandleAsync(ParseResult parseResult, CancellationToken ct) + public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) { var filePath = parseResult.GetValue("file")!; if (!File.Exists(filePath)) { Console.WriteLine($"[red]File not found: {filePath}[/]"); - return Task.CompletedTask; + return; } try { var data = File.ReadAllBytes(filePath); - var timeline = serializer.DeserializeAsync(data, ct).GetAwaiter().GetResult(); - + var timeline = await serializer.DeserializeAsync(data, ct); + //timeline.Normalize(); - + var player = playerFactory.Create( timeline, initialSpeed: 1.5, acceleration: 2.5); - - - - - - - - - - - + var random = new Random(); var eventsSample = timeline.Events // .OrderBy(_ => random.Next()) @@ -99,7 +82,7 @@ public Task HandleAsync(ParseResult parseResult, CancellationToken ct) } ) ); - + /* var actorResult = ActorName.Create("Bryan Chen"); if (!actorResult.IsSuccess) @@ -111,11 +94,11 @@ public Task HandleAsync(ParseResult parseResult, CancellationToken ct) Console.WriteLine($"[red] actorc: {actorc.ToString()}[/]"); var spec = CommitQueries.EnrichedMerges() .And(CommitQueries.ByAuthor(actorc)); - + var filteredEvents = timeline.Events .Where(e => spec.IsSatisfiedBy(e)) .ToList(); - + var grouped = filteredEvents .GroupBy(e => e.CommitSha) .Select(g => new { CommitSha = g.Key, Count = g.Count() }) @@ -125,7 +108,7 @@ public Task HandleAsync(ParseResult parseResult, CancellationToken ct) { Console.WriteLine($"{g.CommitSha}: {g.Count} razy"); } - + */ // var spec2 = OwnershipQueries.DirectoryOwners("src/Engine"); var spec2 = OwnershipQueries.DirectoryOwners("extensions/csharp/snippets/"); @@ -137,9 +120,7 @@ public Task HandleAsync(ParseResult parseResult, CancellationToken ct) .ToList(); Console.WriteLine($"Autorzy zmian w katalogu extensions/csharp/snippets/: {string.Join(", ", authorsInDir)}"); - - - + var parentSpec = CommitRelationshipQueries.Parent("0a2f0cbc5c7ebc4573ba93c7b4c007efb1110856"); var childSpec = CommitRelationshipQueries.Children("0a2f0cbc5c7ebc4573ba93c7b4c007efb1110856"); @@ -169,7 +150,7 @@ public Task HandleAsync(ParseResult parseResult, CancellationToken ct) }; // Console.WriteLine(JsonSerializer.Serialize(hierarchy, new JsonSerializerOptions{ WriteIndented = true })); - + using var debugWindow = new DebugWindow(diagnostics); using var window = new PlayerWindow(timeline, playerFactory, renderFactory, diagnostics); window.SetDebugWindow(debugWindow); @@ -178,8 +159,7 @@ public Task HandleAsync(ParseResult parseResult, CancellationToken ct) catch (Exception ex) { Console.WriteLine($"[red]Failed to read or deserialize file: {ex.Message}[/]"); + return; } - - return Task.CompletedTask; } } diff --git a/src/CredentialTrace/Interfaces/IWorkspaceTimelineStorage.cs b/src/CredentialTrace/Interfaces/IWorkspaceTimelineStorage.cs new file mode 100644 index 0000000..742b4d7 --- /dev/null +++ b/src/CredentialTrace/Interfaces/IWorkspaceTimelineStorage.cs @@ -0,0 +1,36 @@ +using ChangeTrace.CredentialTrace.Profiles; + +namespace ChangeTrace.CredentialTrace.Interfaces; + +/// +/// Stores and lists timeline files for workspaces. +/// +internal interface IWorkspaceTimelineStorage +{ + /// + /// Creates workspace specific timeline path for an export. + /// + Task CreateTimelinePathAsync( + WorkspaceProfile workspace, + string repositorySource, + DateTimeOffset exportedAt, + string uniqueId, + CancellationToken ct = default); + + /// + /// Saves metadata next to timeline file. + /// + Task SaveMetadataAsync( + string timelinePath, + WorkspaceProfile workspace, + string repositorySource, + DateTimeOffset exportedAt, + CancellationToken ct = default); + + /// + /// Lists timeline files stored for the workspace. + /// + Task> ListTimelinesAsync( + WorkspaceProfile workspace, + CancellationToken ct = default); +} diff --git a/src/CredentialTrace/Profiles/WorkspaceTimelineFile.cs b/src/CredentialTrace/Profiles/WorkspaceTimelineFile.cs new file mode 100644 index 0000000..1cd1972 --- /dev/null +++ b/src/CredentialTrace/Profiles/WorkspaceTimelineFile.cs @@ -0,0 +1,32 @@ +namespace ChangeTrace.CredentialTrace.Profiles; + +/// +/// Represents a timeline file stored for a workspace. +/// +internal sealed class WorkspaceTimelineFile +{ + /// + /// Full path to the timeline file. + /// + public required string Path { get; init; } + + /// + /// Timeline file name. + /// + public required string FileName { get; init; } + + /// + /// File size in bytes. + /// + public long SizeBytes { get; init; } + + /// + /// Last modified time in UTC. + /// + public DateTimeOffset LastModifiedUtc { get; init; } + + /// + /// Metadata loaded from the sidecar file. + /// + public WorkspaceTimelineMetadata? Metadata { get; init; } +} diff --git a/src/CredentialTrace/Profiles/WorkspaceTimelineMetadata.cs b/src/CredentialTrace/Profiles/WorkspaceTimelineMetadata.cs new file mode 100644 index 0000000..2103b9f --- /dev/null +++ b/src/CredentialTrace/Profiles/WorkspaceTimelineMetadata.cs @@ -0,0 +1,42 @@ +namespace ChangeTrace.CredentialTrace.Profiles; + +/// +/// Describes a workspace-managed timeline export. +/// +internal sealed class WorkspaceTimelineMetadata +{ + /// + /// Workspace that owns the timeline. + /// + public required Ulid WorkspaceId { get; init; } + + /// + /// Workspace display name at export time. + /// + public required string WorkspaceName { get; init; } + + /// + /// Original repository source used for export. + /// + public required string RepositorySource { get; init; } + + /// + /// Repository owner when resolved from a remote source. + /// + public string? RepositoryOwner { get; init; } + + /// + /// Repository name resolved from the source. + /// + public string? RepositoryName { get; init; } + + /// + /// Export creation time in UTC. + /// + public required DateTimeOffset ExportedAtUtc { get; init; } + + /// + /// Full path to the timeline file. + /// + public required string TimelinePath { get; init; } +} diff --git a/src/CredentialTrace/Services/WorkspaceTimelineStorage.cs b/src/CredentialTrace/Services/WorkspaceTimelineStorage.cs new file mode 100644 index 0000000..fd8e701 --- /dev/null +++ b/src/CredentialTrace/Services/WorkspaceTimelineStorage.cs @@ -0,0 +1,299 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using ChangeTrace.Configuration.Converters; +using ChangeTrace.Configuration.Discovery; +using ChangeTrace.CredentialTrace.Interfaces; +using ChangeTrace.CredentialTrace.Profiles; +using Microsoft.Extensions.DependencyInjection; + +namespace ChangeTrace.CredentialTrace.Services; + +/// +/// Stores timeline files and metadata for workspaces. +/// +[AutoRegister(ServiceLifetime.Singleton)] +internal sealed class WorkspaceTimelineStorage : IWorkspaceTimelineStorage +{ + private const string TimelineExtension = ".gittrace"; + private const string MetadataExtension = ".metadata.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new UlidJsonConverter() } + }; + + private readonly string _storageRoot; + private readonly IProfileStore? _organizationStore; + + /// + /// Creates torage service rooted at the application directory. + /// + public WorkspaceTimelineStorage(IProfileStore organizationStore) + : this(AppContext.BaseDirectory, organizationStore) + { + } + + /// + /// Creates storage service for the specified root directory. + /// + internal WorkspaceTimelineStorage( + string storageRoot, + IProfileStore? organizationStore = null) + { + _storageRoot = storageRoot; + _organizationStore = organizationStore; + } + + /// + public async Task CreateTimelinePathAsync( + WorkspaceProfile workspace, + string repositorySource, + DateTimeOffset exportedAt, + string uniqueId, + CancellationToken ct = default) + { + var repository = ResolveRepository(repositorySource); + + var repositoryDirectory = repository.Owner is null + ? Slug(repository.Name) + : $"{Slug(repository.Owner)}-{Slug(repository.Name)}"; + + var organizationDirectory = await ResolveOrganizationDirectoryAsync(workspace, ct); + var workspaceDirectory = Slug(workspace.Name); + + var fileName = + $"{exportedAt.UtcDateTime:yyyyMMddTHHmmssZ}-{Slug(uniqueId)}{TimelineExtension}"; + + return Path.Combine( + _storageRoot, + "workspaces", + organizationDirectory, + workspaceDirectory, + "timelines", + repositoryDirectory, + fileName); + } + + /// + public async Task SaveMetadataAsync( + string timelinePath, + WorkspaceProfile workspace, + string repositorySource, + DateTimeOffset exportedAt, + CancellationToken ct = default) + { + var repository = ResolveRepository(repositorySource); + + var metadata = new WorkspaceTimelineMetadata + { + WorkspaceId = workspace.Id, + WorkspaceName = workspace.Name, + RepositorySource = repositorySource, + RepositoryOwner = repository.Owner, + RepositoryName = repository.Name, + ExportedAtUtc = exportedAt.ToUniversalTime(), + TimelinePath = timelinePath + }; + + var metadataPath = GetMetadataPath(timelinePath); + var directory = Path.GetDirectoryName(metadataPath); + + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var json = JsonSerializer.Serialize(metadata, JsonOptions); + await File.WriteAllTextAsync(metadataPath, json, ct); + } + + /// + public async Task> ListTimelinesAsync( + WorkspaceProfile workspace, + CancellationToken ct = default) + { + var root = await GetWorkspaceTimelineRootAsync(workspace, ct); + + if (!Directory.Exists(root)) + return []; + + return Directory + .EnumerateFiles( + root, + "*" + TimelineExtension, + SearchOption.AllDirectories) + .Select(CreateTimelineFile) + .OrderByDescending(file => + file.Metadata?.ExportedAtUtc ?? file.LastModifiedUtc) + .ToList(); + } + + /// + /// Gets the timeline root directory for a workspace. + /// + private async Task GetWorkspaceTimelineRootAsync( + WorkspaceProfile workspace, + CancellationToken ct) + { + return Path.Combine( + _storageRoot, + "workspaces", + await ResolveOrganizationDirectoryAsync(workspace, ct), + Slug(workspace.Name), + "timelines"); + } + + /// + /// Resolves the organization directory name for a workspace. + /// + private async Task ResolveOrganizationDirectoryAsync( + WorkspaceProfile workspace, + CancellationToken ct) + { + if (_organizationStore == null) + return Slug(workspace.OrganizationId.ToString()); + + var organization = await _organizationStore.GetAsync(workspace.OrganizationId, ct); + + return organization == null + ? Slug(workspace.OrganizationId.ToString()) + : Slug(organization.Name); + } + + /// + /// Creates timeline file metadata from a file path. + /// + private static WorkspaceTimelineFile CreateTimelineFile(string path) + { + var info = new FileInfo(path); + + return new WorkspaceTimelineFile + { + Path = path, + FileName = info.Name, + SizeBytes = info.Exists ? info.Length : 0, + LastModifiedUtc = info.Exists + ? info.LastWriteTimeUtc + : DateTimeOffset.MinValue, + Metadata = LoadMetadata(path) + }; + } + + /// + /// Loads timeline metadata if available. + /// + private static WorkspaceTimelineMetadata? LoadMetadata(string timelinePath) + { + var metadataPath = GetMetadataPath(timelinePath); + + if (!File.Exists(metadataPath)) + return null; + + try + { + var json = File.ReadAllText(metadataPath); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + return null; + } + } + + /// + /// Gets metadata file path for a timeline. + /// + private static string GetMetadataPath(string timelinePath) + => timelinePath + MetadataExtension; + + /// + /// Resolves repository information from a source string. + /// + private static RepositoryDescriptor ResolveRepository(string source) + { + if (TryResolveRemoteRepository(source, out var remote)) + return remote; + + var name = source.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar); + + name = Path.GetFileName(name); + + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + + return new RepositoryDescriptor( + null, + string.IsNullOrWhiteSpace(name) ? "repository" : name); + } + + /// + /// Attempts to resolve repository owner and name from a remote URL. + /// + private static bool TryResolveRemoteRepository( + string source, + out RepositoryDescriptor repository) + { + repository = default; + + var isRemote = + source.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + source.StartsWith("git@", StringComparison.OrdinalIgnoreCase); + + if (!isRemote) + return false; + + try + { + var url = source.Replace( + "git@github.com:", + "https://github.com/"); + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + var segments = uri.AbsolutePath + .Trim('/') + .Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length < 2) + return false; + + var name = segments[1].EndsWith(".git", StringComparison.OrdinalIgnoreCase) + ? segments[1][..^4] + : segments[1]; + + repository = new RepositoryDescriptor(segments[0], name); + return true; + } + catch + { + return false; + } + } + + /// + /// Converts a value into a filesystem-safe slug. + /// + private static string Slug(string value) + { + var slug = Regex.Replace( + value.Trim().ToLowerInvariant(), + @"[^a-z0-9._-]+", + "-"); + + slug = slug.Trim('-', '.', '_'); + + return string.IsNullOrWhiteSpace(slug) + ? "unknown" + : slug; + } + + /// + /// Represents repository owner and name. + /// + private readonly record struct RepositoryDescriptor( + string? Owner, + string Name); +}