diff --git a/.prettierignore b/.prettierignore index eecf083..9b21047 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,3 +17,6 @@ LICENSE-CSLOL.md # Tauri src-tauri/ + +# Spec-kit metadata +.specify/ diff --git a/.specify/extensions.yml b/.specify/extensions.yml new file mode 100644 index 0000000..efbbda9 --- /dev/null +++ b/.specify/extensions.yml @@ -0,0 +1,148 @@ +installed: [] +settings: + auto_execute_hooks: true +hooks: + before_constitution: + - extension: git + command: speckit.git.initialize + enabled: true + optional: false + prompt: Execute speckit.git.initialize? + description: Initialize Git repository before constitution setup + condition: null + before_specify: + - extension: git + command: speckit.git.feature + enabled: true + optional: false + prompt: Execute speckit.git.feature? + description: Create feature branch before specification + condition: null + before_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before clarification? + description: Auto-commit before spec clarification + condition: null + before_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before planning? + description: Auto-commit before implementation planning + condition: null + before_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before task generation? + description: Auto-commit before task generation + condition: null + before_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before implementation? + description: Auto-commit before implementation + condition: null + before_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before checklist? + description: Auto-commit before checklist generation + condition: null + before_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before analysis? + description: Auto-commit before analysis + condition: null + before_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before issue sync? + description: Auto-commit before tasks-to-issues conversion + condition: null + after_constitution: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit constitution changes? + description: Auto-commit after constitution update + condition: null + after_specify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit specification changes? + description: Auto-commit after specification + condition: null + after_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit clarification changes? + description: Auto-commit after spec clarification + condition: null + after_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit plan changes? + description: Auto-commit after implementation planning + condition: null + after_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit task changes? + description: Auto-commit after task generation + condition: null + after_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit implementation changes? + description: Auto-commit after implementation + condition: null + after_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit checklist changes? + description: Auto-commit after checklist generation + condition: null + after_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit analysis results? + description: Auto-commit after analysis + condition: null + after_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit after syncing issues? + description: Auto-commit after tasks-to-issues conversion + condition: null diff --git a/.specify/extensions/.registry b/.specify/extensions/.registry new file mode 100644 index 0000000..f68d4a1 --- /dev/null +++ b/.specify/extensions/.registry @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "extensions": { + "git": { + "version": "1.0.0", + "source": "local", + "manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79", + "enabled": true, + "priority": 10, + "registered_commands": { + "claude": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ] + }, + "registered_skills": [], + "installed_at": "2026-04-18T14:15:39.856669+00:00" + } + } +} \ No newline at end of file diff --git a/.specify/extensions/git/README.md b/.specify/extensions/git/README.md new file mode 100644 index 0000000..b233883 --- /dev/null +++ b/.specify/extensions/git/README.md @@ -0,0 +1,101 @@ +# Git Branching Workflow Extension + +Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit. + +## Overview + +This extension provides Git operations as an optional, self-contained module. It manages: + +- **Repository initialization** with configurable commit messages +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Branch validation** to ensure branches follow naming conventions +- **Git remote detection** for GitHub integration (e.g., issue creation) +- **Auto-commit** after core commands (configurable per-command with custom messages) + +## Commands + +| Command | Description | +| ------------------------ | -------------------------------------------------------------------------- | +| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message | +| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.validate` | Validate current branch follows feature branch naming conventions | +| `speckit.git.remote` | Detect Git remote URL for GitHub integration | +| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) | + +## Hooks + +| Event | Command | Optional | Description | +| ---------------------- | ------------------------ | -------- | ------------------------------------------------- | +| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution | +| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification | +| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification | +| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning | +| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation | +| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation | +| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist | +| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis | +| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync | +| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update | +| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification | +| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification | +| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning | +| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation | +| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation | +| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist | +| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis | +| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync | + +## Configuration + +Configuration is stored in `.specify/extensions/git/git-config.yml`: + +```yaml +# Branch numbering strategy: "sequential" or "timestamp" +branch_numbering: sequential + +# Custom commit message for git init +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit per command (all disabled by default) +# Example: enable auto-commit after specify +auto_commit: + default: false + after_specify: + enabled: true + message: "[Spec Kit] Add specification" +``` + +## Installation + +```bash +# Install the bundled git extension (no network required) +specify extension add git +``` + +## Disabling + +```bash +# Disable the git extension (spec creation continues without branching) +specify extension disable git + +# Re-enable it +specify extension enable git +``` + +## Graceful Degradation + +When Git is not installed or the directory is not a Git repository: + +- Spec directories are still created under `specs/` +- Branch creation is skipped with a warning +- Branch validation is skipped with a warning +- Remote detection returns empty results + +## Scripts + +The extension bundles cross-platform scripts: + +- `scripts/bash/create-new-feature.sh` — Bash implementation +- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) +- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation +- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) diff --git a/.specify/extensions/git/commands/speckit.git.commit.md b/.specify/extensions/git/commands/speckit.git.commit.md new file mode 100644 index 0000000..271288c --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.commit.md @@ -0,0 +1,48 @@ +--- +description: "Auto-commit changes after a Spec Kit command completes" +--- + +# Auto-Commit Changes + +Automatically stage and commit all changes after a Spec Kit command completes. + +## Behavior + +This command is invoked as a hook after (or before) core commands. It: + +1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`) +2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section +3. Looks up the specific event key to see if auto-commit is enabled +4. Falls back to `auto_commit.default` if no event-specific key exists +5. Uses the per-command `message` if configured, otherwise a default message +6. If enabled and there are uncommitted changes, runs `git add .` + `git commit` + +## Execution + +Determine the event name from the hook that triggered this command, then run the script: + +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). + +## Configuration + +In `.specify/extensions/git/git-config.yml`: + +```yaml +auto_commit: + default: false # Global toggle — set true to enable for all commands + after_specify: + enabled: true # Override per-command + message: "[Spec Kit] Add specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" +``` + +## Graceful Degradation + +- If Git is not available or the current directory is not a repository: skips with a warning +- If no config file exists: skips (disabled by default) +- If no changes to commit: skips with a message diff --git a/.specify/extensions/git/commands/speckit.git.feature.md b/.specify/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 0000000..14211eb --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.feature.md @@ -0,0 +1,72 @@ +--- +description: "Create a feature branch with sequential or timestamp numbering" +--- + +# Create Feature Branch + +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: + +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: + +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: + +- Do NOT pass `--number` — the script determines the correct next number automatically +- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably +- You must only ever run this script once per feature +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: + +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them + +## Output + +The script outputs JSON with: + +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/.specify/extensions/git/commands/speckit.git.initialize.md b/.specify/extensions/git/commands/speckit.git.initialize.md new file mode 100644 index 0000000..edc3ba4 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.initialize.md @@ -0,0 +1,55 @@ +--- +description: "Initialize a Git repository with an initial commit" +--- + +# Initialize Git Repository + +Initialize a Git repository in the current project directory if one does not already exist. + +## Execution + +Run the appropriate script from the project root: + +- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1` + +If the extension scripts are not found, fall back to: + +- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"` +- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"` + +The script handles all checks internally: + +- Skips if Git is not available +- Skips if already inside a Git repository +- Runs `git init`, `git add .`, and `git commit` with an initial commit message + +## Customization + +Replace the script to add project-specific Git initialization steps: + +- Custom `.gitignore` templates +- Default branch naming (`git config init.defaultBranch`) +- Git LFS setup +- Git hooks installation +- Commit signing configuration +- Git Flow initialization + +## Output + +On success: + +- `✓ Git repository initialized` + +## Graceful Degradation + +If Git is not installed: + +- Warn the user +- Skip repository initialization +- The project continues to function without Git (specs can still be created under `specs/`) + +If Git is installed but `git init`, `git add .`, or `git commit` fails: + +- Surface the error to the user +- Stop this command rather than continuing with a partially initialized repository diff --git a/.specify/extensions/git/commands/speckit.git.remote.md b/.specify/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 0000000..73bec95 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.remote.md @@ -0,0 +1,47 @@ +--- +description: "Detect Git remote URL for GitHub integration" +--- + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: + +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: + +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information diff --git a/.specify/extensions/git/commands/speckit.git.validate.md b/.specify/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 0000000..18e38cc --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.validate.md @@ -0,0 +1,52 @@ +--- +description: "Validate current branch follows feature branch naming conventions" +--- + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): + +- Output: `✓ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `✓ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: + +- Output: `✗ Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: + +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning diff --git a/.specify/extensions/git/config-template.yml b/.specify/extensions/git/config-template.yml new file mode 100644 index 0000000..8c414ba --- /dev/null +++ b/.specify/extensions/git/config-template.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/extension.yml b/.specify/extensions/git/extension.yml new file mode 100644 index 0000000..13c1977 --- /dev/null +++ b/.specify/extensions/git/extension.yml @@ -0,0 +1,140 @@ +schema_version: "1.0" + +extension: + id: git + name: "Git Branching Workflow" + version: "1.0.0" + description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.git.feature + file: commands/speckit.git.feature.md + description: "Create a feature branch with sequential or timestamp numbering" + - name: speckit.git.validate + file: commands/speckit.git.validate.md + description: "Validate current branch follows feature branch naming conventions" + - name: speckit.git.remote + file: commands/speckit.git.remote.md + description: "Detect Git remote URL for GitHub integration" + - name: speckit.git.initialize + file: commands/speckit.git.initialize.md + description: "Initialize a Git repository with an initial commit" + - name: speckit.git.commit + file: commands/speckit.git.commit.md + description: "Auto-commit changes after a Spec Kit command completes" + + config: + - name: "git-config.yml" + template: "config-template.yml" + description: "Git branching configuration" + required: false + +hooks: + before_constitution: + command: speckit.git.initialize + optional: false + description: "Initialize Git repository before constitution setup" + before_specify: + command: speckit.git.feature + optional: false + description: "Create feature branch before specification" + before_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before clarification?" + description: "Auto-commit before spec clarification" + before_plan: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before planning?" + description: "Auto-commit before implementation planning" + before_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before task generation?" + description: "Auto-commit before task generation" + before_implement: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before implementation?" + description: "Auto-commit before implementation" + before_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before checklist?" + description: "Auto-commit before checklist generation" + before_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before analysis?" + description: "Auto-commit before analysis" + before_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before issue sync?" + description: "Auto-commit before tasks-to-issues conversion" + after_constitution: + command: speckit.git.commit + optional: true + prompt: "Commit constitution changes?" + description: "Auto-commit after constitution update" + after_specify: + command: speckit.git.commit + optional: true + prompt: "Commit specification changes?" + description: "Auto-commit after specification" + after_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit clarification changes?" + description: "Auto-commit after spec clarification" + after_plan: + command: speckit.git.commit + optional: true + prompt: "Commit plan changes?" + description: "Auto-commit after implementation planning" + after_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit task changes?" + description: "Auto-commit after task generation" + after_implement: + command: speckit.git.commit + optional: true + prompt: "Commit implementation changes?" + description: "Auto-commit after implementation" + after_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit checklist changes?" + description: "Auto-commit after checklist generation" + after_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit analysis results?" + description: "Auto-commit after analysis" + after_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit after syncing issues?" + description: "Auto-commit after tasks-to-issues conversion" + +tags: + - "git" + - "branching" + - "workflow" + +config: + defaults: + branch_numbering: sequential + init_commit_message: "[Spec Kit] Initial commit" diff --git a/.specify/extensions/git/git-config.yml b/.specify/extensions/git/git-config.yml new file mode 100644 index 0000000..8c414ba --- /dev/null +++ b/.specify/extensions/git/git-config.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/scripts/bash/auto-commit.sh b/.specify/extensions/git/scripts/bash/auto-commit.sh new file mode 100644 index 0000000..f0b4231 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/auto-commit.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Git extension: auto-commit.sh +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.sh +# e.g.: auto-commit.sh after_specify + +set -e + +EVENT_NAME="${1:-}" +if [ -z "$EVENT_NAME" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped auto-commit" >&2 + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2 + exit 0 +fi + +# Read per-command config from git-config.yml +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +_enabled=false +_commit_msg="" + +if [ -f "$_config_file" ]; then + # Parse the auto_commit section for this event. + # Look for auto_commit..enabled and .message + # Also check auto_commit.default as fallback. + _in_auto_commit=false + _in_event=false + _default_enabled=false + + while IFS= read -r _line; do + # Detect auto_commit: section + if echo "$_line" | grep -q '^auto_commit:'; then + _in_auto_commit=true + _in_event=false + continue + fi + + # Exit auto_commit section on next top-level key + if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then + break + fi + + if $_in_auto_commit; then + # Check default key + if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _default_enabled=true + fi + + # Detect our event subsection + if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then + _in_event=true + continue + fi + + # Inside our event subsection + if $_in_event; then + # Exit on next sibling key (same indent level as event name) + if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then + _in_event=false + continue + fi + if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _enabled=true + [ "$_val" = "false" ] && _enabled=false + fi + if echo "$_line" | grep -Eq '[[:space:]]+message:'; then + _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + fi + fi + fi + done < "$_config_file" + + # If event-specific key not found, use default + if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then + # Only use default if the event wasn't explicitly set to false + # Check if event section existed at all + if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then + _enabled=true + fi + fi +else + # No config file — auto-commit disabled by default + exit 0 +fi + +if [ "$_enabled" != "true" ]; then + exit 0 +fi + +# Check if there are changes to commit +if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then + echo "[specify] No changes to commit after $EVENT_NAME" >&2 + exit 0 +fi + +# Derive a human-readable command name from the event +# e.g., after_specify -> specify, before_plan -> plan +_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//') +_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after') + +# Use custom message if configured, otherwise default +if [ -z "$_commit_msg" ]; then + _commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}" +fi + +# Stage and commit +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "[OK] Changes committed ${_phase} ${_command_name}" >&2 diff --git a/.specify/extensions/git/scripts/bash/create-new-feature.sh b/.specify/extensions/git/scripts/bash/create-new-feature.sh new file mode 100644 index 0000000..286aaf7 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/create-new-feature.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# Git extension: create-new-feature.sh +# Adapted from core scripts/bash/create-new-feature.sh for extension layout. +# Sources common.sh from the project's installed scripts, falling back to +# git-common.sh for minimal git helpers. + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo 'Error: --number must be a non-negative integer' >&2 + exit 1 + fi + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name without creating the branch" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches and return next available number. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + local highest_spec=$(get_highest_from_specs "$specs_dir") + + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# --------------------------------------------------------------------------- +# Source common.sh for resolve_template, json_escape, get_repo_root, has_git. +# +# Search locations in priority order: +# 1. .specify/scripts/bash/common.sh under the project root (installed project) +# 2. scripts/bash/common.sh under the project root (source checkout fallback) +# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root by walking up from the script location +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +_common_loaded=false +_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true + +if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" + _common_loaded=true +elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/scripts/bash/common.sh" + _common_loaded=true +elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then + source "$SCRIPT_DIR/git-common.sh" + _common_loaded=true +fi + +if [ "$_common_loaded" != "true" ]; then + echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2 + exit 1 +fi + +# Resolve repository root +if type get_repo_root >/dev/null 2>&1; then + REPO_ROOT=$(get_repo_root) +elif git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) +elif [ -n "$_PROJECT_ROOT" ]; then + REPO_ROOT="$_PROJECT_ROOT" +else + echo "Error: Could not determine repository root." >&2 + exit 1 +fi + +# Check if git is available at this repo root +if type has_git >/dev/null 2>&1; then + if has_git "$REPO_ROOT"; then + HAS_GIT=true + else + HAS_GIT=false + fi +elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" + +# Function to generate branch name with stop word filtering +generate_branch_name() { + local description="$1" + + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + local meaningful_words=() + for word in $clean_name; do + [ -z "$word" ] && continue + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -qw -- "${word^^}"; then + meaningful_words+=("$word") + fi + fi + done + + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + else + FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" + fi +else + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi + + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi + + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi +fi + +# GitHub enforces a 244-byte limit on branch names +MAX_BRANCH_LENGTH=244 +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." + exit 1 +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' + fi + else + if type json_escape >/dev/null 2>&1; then + _je_branch=$(json_escape "$BRANCH_NAME") + _je_num=$(json_escape "$FEATURE_NUM") + else + _je_branch="$BRANCH_NAME" + _je_num="$FEATURE_NUM" + fi + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" + else + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/.specify/extensions/git/scripts/bash/git-common.sh b/.specify/extensions/git/scripts/bash/git-common.sh new file mode 100644 index 0000000..b78356d --- /dev/null +++ b/.specify/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git-specific common functions for the git extension. +# Extracted from scripts/bash/common.sh — contains only git-specific +# branch validation and detection logic. + +# Check if we have git available at the repo root +has_git() { + local repo_root="${1:-$(pwd)}" + { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \ + command -v git >/dev/null 2>&1 && \ + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. +check_feature_branch() { + local raw="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} diff --git a/.specify/extensions/git/scripts/bash/initialize-repo.sh b/.specify/extensions/git/scripts/bash/initialize-repo.sh new file mode 100644 index 0000000..296e363 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/initialize-repo.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git extension: initialize-repo.sh +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. + +set -e + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Read commit message from extension config, fall back to default +COMMIT_MSG="[Spec Kit] Initial commit" +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +if [ -f "$_config_file" ]; then + _msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + if [ -n "$_msg" ]; then + COMMIT_MSG="$_msg" + fi +fi + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped repository initialization" >&2 + exit 0 +fi + +# Check if already a git repo +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Git repository already initialized; skipping" >&2 + exit 0 +fi + +# Initialize +_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; } +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "✓ Git repository initialized" >&2 diff --git a/.specify/extensions/git/scripts/powershell/auto-commit.ps1 b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 new file mode 100644 index 0000000..4a8b0e0 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 @@ -0,0 +1,169 @@ +#!/usr/bin/env pwsh +# Git extension: auto-commit.ps1 +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.ps1 +# e.g.: auto-commit.ps1 after_specify +param( + [Parameter(Position = 0, Mandatory = $true)] + [string]$EventName +) +$ErrorActionPreference = 'Stop' + +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped auto-commit" + exit 0 +} + +# Temporarily relax ErrorActionPreference so git stderr warnings +# (e.g. CRLF notices on Windows) do not become terminating errors. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { + Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" + exit 0 +} + +# Read per-command config from git-config.yml +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +$enabled = $false +$commitMsg = "" + +if (Test-Path $configFile) { + # Parse YAML to find auto_commit section + $inAutoCommit = $false + $inEvent = $false + $defaultEnabled = $false + + foreach ($line in Get-Content $configFile) { + # Detect auto_commit: section + if ($line -match '^auto_commit:') { + $inAutoCommit = $true + $inEvent = $false + continue + } + + # Exit auto_commit section on next top-level key + if ($inAutoCommit -and $line -match '^[a-z]') { + break + } + + if ($inAutoCommit) { + # Check default key + if ($line -match '^\s+default:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $defaultEnabled = $true } + } + + # Detect our event subsection + if ($line -match "^\s+${EventName}:") { + $inEvent = $true + continue + } + + # Inside our event subsection + if ($inEvent) { + # Exit on next sibling key (2-space indent, not 4+) + if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') { + $inEvent = $false + continue + } + if ($line -match '\s+enabled:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $enabled = $true } + if ($val -eq 'false') { $enabled = $false } + } + if ($line -match '\s+message:\s*(.+)$') { + $commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + } + } + } + } + + # If event-specific key not found, use default + if (-not $enabled -and $defaultEnabled) { + $hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet + if (-not $hasEventKey) { + $enabled = $true + } + } +} else { + # No config file — auto-commit disabled by default + exit 0 +} + +if (-not $enabled) { + exit 0 +} + +# Check if there are changes to commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE + git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE + $untracked = git ls-files --others --exclude-standard 2>$null +} finally { + $ErrorActionPreference = $savedEAP +} + +if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { + Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray + exit 0 +} + +# Derive a human-readable command name from the event +$commandName = $EventName -replace '^after_', '' -replace '^before_', '' +$phase = if ($EventName -match '^before_') { 'before' } else { 'after' } + +# Use custom message if configured, otherwise default +if (-not $commitMsg) { + $commitMsg = "[Spec Kit] Auto-commit $phase $commandName" +} + +# Stage and commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate, +# while still allowing redirected error output to be captured for diagnostics. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} finally { + $ErrorActionPreference = $savedEAP +} + +Write-Host "[OK] Changes committed $phase $commandName" diff --git a/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 0000000..b579f05 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,403 @@ +#!/usr/bin/env pwsh +# Git extension: create-new-feature.ps1 +# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. +# Sources common.ps1 from the project's installed scripts, falling back to +# git-common.ps1 for minimal git helpers. +[CmdletBinding()] +param( + [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, + [string]$ShortName, + [Parameter()] + [long]$Number = 0, + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "" + Write-Host "Options:" + Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name without creating the branch" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" + Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" + exit 0 +} + +if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + exit 1 +} + +$featureDesc = ($FeatureDescription -join ' ').Trim() + +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + +function Get-HighestNumberFromSpecs { + param([string]$SpecsDir) + + [long]$highest = 0 + if (Test-Path $SpecsDir) { + Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + return $highest +} + +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + return $highest +} + +function Get-HighestNumberFromBranches { + param() + + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + } + return Get-HighestNumberFromNames -Names $cleanNames + } + } catch { + Write-Verbose "Could not check Git branches: $_" + } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + +function Get-NextBranchNumber { + param( + [string]$SpecsDir, + [switch]$SkipFetch + ) + + if ($SkipFetch) { + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + try { + git fetch --all --prune 2>$null | Out-Null + } catch { } + $highestBranch = Get-HighestNumberFromBranches + } + + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir + $maxNum = [Math]::Max($highestBranch, $highestSpec) + return $maxNum + 1 +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} + +# --------------------------------------------------------------------------- +# Source common.ps1 from the project's installed scripts. +# Search locations in priority order: +# 1. .specify/scripts/powershell/common.ps1 under the project root +# 2. scripts/powershell/common.ps1 under the project root (source checkout) +# 3. git-common.ps1 next to this script (minimal fallback) +# --------------------------------------------------------------------------- +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot +$commonLoaded = $false + +if ($projectRoot) { + $candidates = @( + (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"), + (Join-Path $projectRoot "scripts/powershell/common.ps1") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + . $candidate + $commonLoaded = $true + break + } + } +} + +if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) { + . "$PSScriptRoot/git-common.ps1" + $commonLoaded = $true +} + +if (-not $commonLoaded) { + throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." +} + +# Resolve repository root +if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { + $repoRoot = Get-RepoRoot +} elseif ($projectRoot) { + $repoRoot = $projectRoot +} else { + throw "Could not determine repository root." +} + +# Check if git is available +if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit +} else { + try { + git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + $hasGit = ($LASTEXITCODE -eq 0) + } catch { + $hasGit = $false + } +} + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' + +function Get-BranchName { + param([string]$Description) + + $stopWords = @( + 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', + 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', + 'want', 'need', 'add', 'get', 'set' + ) + + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + $meaningfulWords = @() + foreach ($word in $words) { + if ($stopWords -contains $word) { continue } + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -match "\b$($word.ToUpper())\b") { + $meaningfulWords += $word + } + } + + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." + } + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } +} else { + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } + + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } + + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } +} + +$maxBranchLength = 244 +if ($branchName.Length -gt $maxBranchLength) { + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + + $originalBranchName = $branchName + $branchName = "$featureNum-$truncatedSuffix" + + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" + Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" +} + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + $branchCreateError = '' + try { + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + $branchCreateError = $_.Exception.Message + } + + if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + if ($currentBranch -eq $branchName) { + # Already on the target branch + } else { + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } + exit 1 + } + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } + } else { + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } + exit 1 + } + } + } else { + if ($Json) { + [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName") + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } + } + + $env:SPECIFY_FEATURE = $branchName +} + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + FEATURE_NUM = $featureNum + HAS_GIT = $hasGit + } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "FEATURE_NUM: $featureNum" + Write-Output "HAS_GIT: $hasGit" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } +} diff --git a/.specify/extensions/git/scripts/powershell/git-common.ps1 b/.specify/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 0000000..8221000 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh +# Git-specific common functions for the git extension. +# Extracted from scripts/powershell/common.ps1 — contains only git-specific +# branch validation and detection logic. + +function Test-HasGit { + param([string]$RepoRoot = (Get-Location)) + try { + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false } + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false } + git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + +function Test-FeatureBranch { + param( + [string]$Branch, + [bool]$HasGit = $true + ) + + # For non-git repos, we can't enforce branch naming but still provide output + if (-not $HasGit) { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" + return $true + } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false + } + return $true +} diff --git a/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 new file mode 100644 index 0000000..324240a --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 @@ -0,0 +1,69 @@ +#!/usr/bin/env pwsh +# Git extension: initialize-repo.ps1 +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. +$ErrorActionPreference = 'Stop' + +# Find project root +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Read commit message from extension config, fall back to default +$commitMsg = "[Spec Kit] Initial commit" +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +if (Test-Path $configFile) { + foreach ($line in Get-Content $configFile) { + if ($line -match '^init_commit_message:\s*(.+)$') { + $val = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + if ($val) { $commitMsg = $val } + break + } + } +} + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped repository initialization" + exit 0 +} + +# Check if already a git repo +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Warning "[specify] Git repository already initialized; skipping" + exit 0 + } +} catch { } + +# Initialize +try { + $out = git init -q 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" } + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} + +Write-Host "✓ Git repository initialized" diff --git a/.specify/init-options.json b/.specify/init-options.json index b9ef100..ad8414a 100644 --- a/.specify/init-options.json +++ b/.specify/init-options.json @@ -1,11 +1,11 @@ { "ai": "claude", - "ai_commands_dir": null, - "ai_skills": false, + "ai_skills": true, "branch_numbering": "sequential", + "context_file": "CLAUDE.md", "here": true, - "offline": false, + "integration": "claude", "preset": null, "script": "ps", - "speckit_version": "0.4.3" + "speckit_version": "0.7.3" } diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 0000000..5234cc0 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,4 @@ +{ + "integration": "claude", + "version": "0.7.3" +} diff --git a/.specify/integrations/claude.manifest.json b/.specify/integrations/claude.manifest.json new file mode 100644 index 0000000..24c67ce --- /dev/null +++ b/.specify/integrations/claude.manifest.json @@ -0,0 +1,16 @@ +{ + "integration": "claude", + "version": "0.7.3", + "installed_at": "2026-04-18T14:15:39.688580+00:00", + "files": { + ".claude/skills/speckit-analyze/SKILL.md": "684ca145ed236bbd6edc813bdcb88061c28818927f2aa4d165325e1ccc477e82", + ".claude/skills/speckit-checklist/SKILL.md": "907fe95c3b7472d304d4a78be1b00332831c3ef2fef1887f41e26334a6275c05", + ".claude/skills/speckit-clarify/SKILL.md": "c1e46d3dab19d67b53ce107b7f9bf35adc776d2d1275f9224142072eb818f84b", + ".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15", + ".claude/skills/speckit-implement/SKILL.md": "c81feb73d69ad89096625bee38e3909c3e64b27e9ec7a475eda0d5eaf709ca9b", + ".claude/skills/speckit-plan/SKILL.md": "5dbf517056a7df98de24835cb44c582b8d9b5c95950f917b129a740ca7f6448b", + ".claude/skills/speckit-specify/SKILL.md": "f78c3e27309aea9ae4e4f71b3abcffafb190f0c64bf709ac7616532ff19f4b1f", + ".claude/skills/speckit-tasks/SKILL.md": "99d60cc468fc2fc2d9103f28e57f56cd8bb343a7af1cc54b734bcd1ee434c026", + ".claude/skills/speckit-taskstoissues/SKILL.md": "ccacc12041d1e27d3afffc7a189ac3d17444836e10eeb4e128b272b5e8ae45cb" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 0000000..9f2bf61 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,6 @@ +{ + "integration": "speckit", + "version": "0.7.3", + "installed_at": "2026-04-18T14:15:39.693125+00:00", + "files": {} +} diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml new file mode 100644 index 0000000..bf18451 --- /dev/null +++ b/.specify/workflows/speckit/workflow.yml @@ -0,0 +1,63 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" diff --git a/.specify/workflows/workflow-registry.json b/.specify/workflows/workflow-registry.json new file mode 100644 index 0000000..d025fce --- /dev/null +++ b/.specify/workflows/workflow-registry.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "workflows": { + "speckit": { + "name": "Full SDD Cycle", + "version": "1.0.0", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "source": "bundled", + "installed_at": "2026-04-18T14:15:39.885121+00:00", + "updated_at": "2026-04-18T14:15:39.885121+00:00" + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 6ba76df..c8f7d6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,3 +281,10 @@ Use `useReducedMotion()` hook from `@/hooks` for component-level checks. Returns - **Windows:** `%APPDATA%\dev.leaguetoolkit.manager\logs\ltk-manager.log` - **Linux/macOS:** `~/.local/share/dev.leaguetoolkit.manager/logs/ltk-manager.log` + + + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan + + diff --git a/Cargo.lock b/Cargo.lock index 78b14cf..377e108 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2851,6 +2851,7 @@ dependencies = [ "ts-rs", "url", "uuid", + "walkdir", "webp", "xxhash-rust", "zip 2.4.2", @@ -2883,6 +2884,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e1fecb09d18694be47efe5175c6ad5a4ec460ddeca28d42b746d9e8c5a6dd03" dependencies = [ + "serde", "strum", ] diff --git a/package.json b/package.json index e514d5b..a6ad51f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router": "^1.139.11", + "@tanstack/react-virtual": "^3.13.24", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-dialog": "^2.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1e5fd6..0228fd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tanstack/react-router': specifier: ^1.139.11 version: 1.139.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/react-virtual': + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 @@ -1082,6 +1085,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.139.11': resolution: {integrity: sha512-85FW4qxlxF3m0Z6aMEaAYHMpUS01oNo4nyDoaN2QP1mwLHkm5C2YhbZvT8i1IYXgS/NxlzVsmbJSpAweO3rv9g==} engines: {node: '>=12'} @@ -1121,6 +1130,9 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@tanstack/virtual-file-routes@1.139.0': resolution: {integrity: sha512-9PImF1d1tovTUIpjFVa0W7Fwj/MHif7BaaczgJJfbv3sDt1Gh+oW9W9uCw9M3ndEJynnp5ZD/TTs0RGubH5ssg==} engines: {node: '>=12'} @@ -4400,6 +4412,12 @@ snapshots: react-dom: 19.2.0(react@19.2.0) use-sync-external-store: 1.6.0(react@19.2.0) + '@tanstack/react-virtual@3.13.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@tanstack/router-core@1.139.11': dependencies: '@tanstack/history': 1.139.0 @@ -4462,6 +4480,8 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tanstack/virtual-core@3.14.0': {} + '@tanstack/virtual-file-routes@1.139.0': {} '@tauri-apps/api@2.10.1': {} diff --git a/specs/012-mod-content-browser/checklists/requirements.md b/specs/012-mod-content-browser/checklists/requirements.md new file mode 100644 index 0000000..8daf77e --- /dev/null +++ b/specs/012-mod-content-browser/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Lightweight Mod Content Browser + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-18 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. +- Implementation-level decisions (specific crates, component library names, icon set, data-fetching library, Rust struct layout, serde feature flags, Cargo bumps) are captured in the companion plan at `../plan.md` and intentionally kept out of this spec. diff --git a/specs/012-mod-content-browser/plan.md b/specs/012-mod-content-browser/plan.md new file mode 100644 index 0000000..37cfb8c --- /dev/null +++ b/specs/012-mod-content-browser/plan.md @@ -0,0 +1,183 @@ +# Plan: Lightweight Mod Content Browser + +Status: Draft +Created: 2026-04-18 +Scope: Workshop project view + +## 1. Goal + +Give creators at-a-glance visibility into what a workshop project actually contains, without the Manager taking on any file editing or preview responsibilities. Editing and deep inspection is handed off to [LTK Forge](https://github.com/LeagueToolkit/ltk-forge). + +## 2. Non-Goals + +Explicitly out of scope — the Manager is a **conductor**, not an editor: + +- File preview (images, models, particles, audio) — Forge's job. +- File editing, conversion, or extraction — Forge's job. +- WAD/bin inspection beyond file listing — already covered by `wad_reports.rs` for installed mods; not duplicated here. +- A new persistence layer (sidecar state, workflow checklist, mod-type hints). +- Any changes to `mod.config.json` schema or the on-disk project layout. +- Live file watching via `notify`. Poll-on-focus is sufficient for v1. + +## 3. User-Facing Shape + +A single collapsible panel on the project overview page titled **"Content"**. Default: expanded. + +For each layer folder under `content/`: + +- Layer header row (`base`, `red-theme`, etc.) with total file count and total size. +- Collapsible file tree beneath the header. Directories expand/collapse on click, show a cumulative file count on the right, and the first-level directories start expanded so creators see their top-level layout without needing to click. Deeper directories stay collapsed until opened. +- Per-file row: kind icon (with kind name on hover), filename, size. Full relative path isn't shown because the tree structure already conveys it. +- Per-layer actions: **Open folder** (shell reveal), **Open in LTK Forge** (see §6). +- Empty state per layer: one line of text + link to the LTK wiki extraction guide. No wizard, no banners. + +**Tree vs. flat list**: the original plan argued for a flat list "grouped by top-level subdirectory" to avoid tree-widget complexity. We reversed that decision during implementation — LoL mod content routinely nests 4–6 levels deep (`assets/characters//skins/skin##/...`), which made the flat presentation with inlined path segments visually noisy and hard to scan. A standard collapsible tree is both more familiar and cheaper to read at a glance. The tree is built frontend-side from the same flat `ContentEntry[]` the backend returns, so no wire-format change. + +Drag-and-drop onto the panel copies files into the currently active layer's content directory. This is the only write operation the browser performs. + +## 4. Backend + +New file: `src-tauri/src/workshop/content.rs`. + +One new command, registered in `main.rs`: + +```rust +#[tauri::command] +get_project_content_tree(project_name: String) -> IpcResult +``` + +Types: + +```rust +pub struct ContentTree { + pub layers: Vec, +} + +pub struct LayerContent { + pub name: String, // "base", "red-theme", ... + pub file_count: usize, + pub total_size_bytes: u64, + pub entries: Vec, +} + +pub struct ContentEntry { + pub relative_path: String, // POSIX-style, relative to content/{layer}/ + pub size_bytes: u64, + pub kind: LeagueFileKind, // from ltk_file, serialized as snake_case +} +``` + +Implementation notes: + +- Walk `content/{layer}/` with `walkdir`. +- Skip hidden files (leading `.`) and symlinks. +- Return every file — **no entry cap**. Real mods can contain tens of thousands of files; frontend virtualization handles render cost. +- Return sorted: layers alphabetical with `base` first; entries by `relative_path`. +- Follows the `AppResult` → `IpcResult` pattern in `commands/workshop.rs`. + +No mutation commands. File drops use the existing file-copy path (whichever dialog/dnd path is wired today — reuse, don't duplicate). + +### 4.1 File Type Identification + +Each entry is tagged with a `LeagueFileKind` from the [`ltk_file`](https://crates.io/crates/ltk_file) crate (already a dependency at `0.2.8`). + +- **Detection strategy: extension-only via `LeagueFileKind::from_extension()`.** Cheap, no I/O, runs inline during the walk. Handles all League-native formats the crate knows: `bin`, `dds`, `tex`, `skn`, `skl`, `anm`, `scb`, `sco`, `mapgeo`, `wgeo`, `bnk`, `wpk`, `stringtable`, `preload`, `luaobj`, `png`, `jpg`, `tga`, `svg`. +- **No magic-byte sniffing in v1.** `identify_from_bytes()` requires reading `MAX_MAGIC_SIZE` bytes per file — meaningful I/O cost across thousands of files, and mod content dirs almost always have correct extensions. Deferred to a future enhancement if a real need surfaces (e.g., files with wrong/missing extensions). +- **Enable the `serde` feature on `ltk_file`** in `src-tauri/Cargo.toml`: + ```toml + ltk_file = { version = "0.2.8", features = ["serde"] } + ``` + This gives us `snake_case` JSON serialization for the enum (`"property_bin"`, `"texture_dds"`, `"wwise_bank"`, etc.) — stable and unambiguous to match on the frontend. +- Files whose extension isn't recognized serialize as `"unknown"`. That's the fallback bucket on the frontend too. + +## 5. Frontend + +New files under `src/modules/workshop/`: + +- `api/useProjectContentTree.ts` — `useQuery`, keyed by project name. `staleTime: 0`, refetch on window focus (TanStack Query already supports this, no custom listener needed). +- `components/ContentBrowser.tsx` — the panel component. +- `components/ContentBrowserLayerSection.tsx` — per-layer rendering. +- Export both through `components/index.ts` → `modules/workshop/index.ts`. + +Rendering rules: + +- Use only wrapped primitives from `@/components` where one exists. The tree rows use native ` + } + trailing={ + openFolder(projectPath, layer.name)} + /> + } + /> + + ); +} + +interface RowShellProps { + layer: WorkshopLayer; + stats?: LayerContent; + selected: boolean; + onSelect: () => void; + projectPath: string; + onRenamed: () => void; + canRename: boolean; + leading: React.ReactNode; + trailing: React.ReactNode; +} + +function RowShell({ + layer, + stats, + selected, + onSelect, + projectPath, + onRenamed, + canRename, + leading, + trailing, +}: RowShellProps) { + const renameLayer = useRenameLayer(); + const toast = useToast(); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(layer.displayName); + const inputRef = useRef(null); + + useEffect(() => { + if (!isRenaming) setRenameValue(layer.displayName); + }, [layer.displayName, isRenaming]); + + function commitRename() { + const trimmed = renameValue.trim(); + if (!trimmed || trimmed === layer.displayName) { + setIsRenaming(false); + return; + } + renameLayer.mutate( + { projectPath, layerName: layer.name, newDisplayName: trimmed }, + { + onSuccess: () => onRenamed(), + onError: (err) => toast.error(`Failed to rename: ${err.message}`), + onSettled: () => setIsRenaming(false), + }, + ); + } + + return ( +
+ {selected && ( +
+ ); +} + +interface RowMenuProps { + canDelete: boolean; + onEdit: () => void; + onDelete: () => void; + onOpenFolder: () => void; +} + +function RowMenu({ canDelete, onEdit, onDelete, onOpenFolder }: RowMenuProps) { + return ( + + } + aria-label="Layer actions" + /> + } + /> + + + + } onClick={onOpenFolder}> + Open Folder + + } onClick={onEdit}> + Edit + + {canDelete && ( + <> + + } + variant="danger" + onClick={onDelete} + > + Delete + + + )} + + + + + ); +} + +async function openFolder(projectPath: string, layerName: string) { + const result = await api.getLayerContentPath(projectPath, layerName); + if (result.ok) await api.revealInExplorer(result.value); +} diff --git a/src/modules/workshop/components/ContentBrowserLayerSection.tsx b/src/modules/workshop/components/ContentBrowserLayerSection.tsx new file mode 100644 index 0000000..737f422 --- /dev/null +++ b/src/modules/workshop/components/ContentBrowserLayerSection.tsx @@ -0,0 +1,80 @@ +import { FolderOpen, Layers, RefreshCw } from "lucide-react"; + +import { IconButton, Tooltip } from "@/components"; +import { api, type LayerContent } from "@/lib/tauri"; +import { formatBytes } from "@/utils"; + +import { ContentTree } from "./ContentTree"; + +interface ContentBrowserLayerSectionProps { + projectPath: string; + layer: LayerContent; + isRefreshing: boolean; + onRefresh: () => void; +} + +export function ContentBrowserLayerSection({ + projectPath, + layer, + isRefreshing, + onRefresh, +}: ContentBrowserLayerSectionProps) { + async function handleOpenFolder() { + await api.revealInExplorer(`${projectPath}/content/${layer.name}`); + } + + return ( +
+
+
+ + {layer.name} + + {layer.fileCount} {layer.fileCount === 1 ? "file" : "files"} + {layer.fileCount > 0 && ` · ${formatBytes(Number(layer.totalSizeBytes))}`} + +
+
+ + + } + variant="ghost" + size="xs" + onClick={onRefresh} + disabled={isRefreshing} + aria-label="Refresh content listing" + /> + + + } + variant="ghost" + size="xs" + onClick={handleOpenFolder} + aria-label={`Open folder for layer ${layer.name}`} + /> + +
+
+ + {layer.entries.length === 0 ? ( + + ) : ( + + )} +
+ ); +} + +function LayerEmptyState() { + return ( +
+

+ No files yet. Extract game files from an existing mod or the game client, then drop them + into this folder. +

+
+ ); +} diff --git a/src/modules/workshop/components/ContentTree.tsx b/src/modules/workshop/components/ContentTree.tsx new file mode 100644 index 0000000..8b08c78 --- /dev/null +++ b/src/modules/workshop/components/ContentTree.tsx @@ -0,0 +1,114 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import type { ContentEntry } from "@/lib/tauri"; + +import { useContentTreeNav } from "../hooks"; +import { + allDirPaths, + buildContentTree, + buildDirFileCounts, + type ContentTreeNode, + flattenTree, +} from "../utils/contentTree"; +import { TreeRow } from "./ContentTreeRow"; +import { ContentTreeRowContextMenu } from "./ContentTreeRowContextMenu"; + +/** Fixed row height (px). Used by the virtualizer so we can precompute row + * positions without per-row measurement. */ +const ROW_HEIGHT = 24; + +interface ContentTreeProps { + entries: readonly ContentEntry[]; + projectPath: string; + layerName: string; +} + +export function ContentTree({ entries, projectPath, layerName }: ContentTreeProps) { + const tree = useMemo(() => buildContentTree(entries), [entries]); + const dirFileCounts = useMemo(() => buildDirFileCounts(tree), [tree]); + const [expanded, setExpanded] = useState>(() => allDirPaths(tree)); + const rows = useMemo(() => flattenTree(tree, expanded), [tree, expanded]); + + const scrollRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 12, + getItemKey: (index) => nodeKey(rows[index]!.node), + }); + + const toggle = useCallback((path: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, []); + + const { focusedIndex, setFocusedIndex, handleKeyDown } = useContentTreeNav({ + rows, + expanded, + onToggle: toggle, + virtualizer, + scrollElementRef: scrollRef, + }); + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]!; + const isSelected = virtualRow.index === focusedIndex; + return ( +
+ + setFocusedIndex(virtualRow.index)} + height={ROW_HEIGHT} + rowIndex={virtualRow.index} + tabIndex={isSelected ? 0 : -1} + /> + +
+ ); + })} +
+
+ ); +} + +function nodeKey(node: ContentTreeNode): string { + return node.type === "dir" ? `d:${node.path}` : `f:${node.entry.relativePath}`; +} diff --git a/src/modules/workshop/components/ContentTreeRow.tsx b/src/modules/workshop/components/ContentTreeRow.tsx new file mode 100644 index 0000000..94586cd --- /dev/null +++ b/src/modules/workshop/components/ContentTreeRow.tsx @@ -0,0 +1,205 @@ +import { ChevronRight, Folder as FolderIconDefault, FolderOpen } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +import { Tooltip } from "@/components"; +import { formatBytes } from "@/utils"; + +import type { ContentTreeNode, DirNode, FileNode } from "../utils/contentTree"; +import { describeFileKind } from "../utils/fileKindIcon"; + +/** Shared row styling. Kept as string constants so the hover/selected variants + * cascade cleanly in Tailwind 4 — selected-hover has to beat plain hover, so + * it appears later in the class string. */ +const ROW_BASE_CLASSES = + "flex items-center gap-1.5 pr-3 select-none text-surface-200 outline-none transition-colors duration-100"; +const ROW_STATE_CLASSES = + "hover:bg-surface-700/70 hover:text-surface-100 " + + "aria-selected:bg-accent-500/15 aria-selected:text-accent-100 " + + "aria-selected:hover:bg-accent-500/25 " + + "focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent-500/70"; + +interface TreeRowProps { + node: ContentTreeNode; + depth: number; + isExpanded: boolean; + isSelected: boolean; + dirFileCount: number; + onToggle: (path: string) => void; + onSelect: () => void; + height: number; + rowIndex: number; + tabIndex: number; +} + +export function TreeRow({ + node, + depth, + isExpanded, + isSelected, + dirFileCount, + onToggle, + onSelect, + height, + rowIndex, + tabIndex, +}: TreeRowProps) { + if (node.type === "dir") { + return ( + + ); + } + return ( + + ); +} + +/** One 14px-wide column per ancestor level, each drawing a 1px vertical guide + * on its left edge. Since every row in the virtual window draws its own rails + * at the same left offsets, the lines appear continuous. */ +function IndentRails({ depth }: { depth: number }) { + if (depth === 0) return null; + return ( + <> + {Array.from({ length: depth }).map((_, i) => ( + - } - /> - - - -

Modified WAD files

-
    - {layerInfo.wadFiles.map((name) => ( -
  • - {name} -
  • - ))} -
-
-
-
- - ); -} - -export function LayerCard({ layer }: { layer: WorkshopLayer }) { - const stringOverrideCount = getStringOverrideCount(layer); - - return ( -
-
-
-
-

{layer.displayName}

- - Priority {layer.priority} - -
- {layer.description && ( -

{layer.description}

- )} -
- {stringOverrideCount > 0 && ( - - {stringOverrideCount} string override{stringOverrideCount !== 1 ? "s" : ""} - - )} -
-
- ); -} diff --git a/src/modules/workshop/layers/components/LockedLayerCard.tsx b/src/modules/workshop/layers/components/LockedLayerCard.tsx deleted file mode 100644 index c43903f..0000000 --- a/src/modules/workshop/layers/components/LockedLayerCard.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { EllipsisVertical, FolderOpen, Lock, Pencil } from "lucide-react"; - -import { IconButton, Menu, Tooltip } from "@/components"; -import { api, type WorkshopLayer, type WorkshopLayerInfo } from "@/lib/tauri"; - -import { getStringOverrideCount, WadFilesBadge } from "./LayerCard"; - -interface LockedLayerCardProps { - layer: WorkshopLayer; - projectPath: string; - layerInfo?: WorkshopLayerInfo; - onEdit: () => void; -} - -export function LockedLayerCard({ layer, projectPath, layerInfo, onEdit }: LockedLayerCardProps) { - const stringOverrideCount = getStringOverrideCount(layer); - - return ( -
-
- -
- -
-
-
-
- -

{layer.displayName}

-
- - Priority {layer.priority} - - -
- {layer.description && ( -

{layer.description}

- )} - {layerInfo && layerInfo.wadFiles.length === 0 && ( -

- No content yet - add WAD files to this layer's folder to get started. -

- )} -
- - {stringOverrideCount > 0 && ( - - {stringOverrideCount} string override{stringOverrideCount !== 1 ? "s" : ""} - - )} - - - } - /> - } - /> - - - - } - onClick={async () => { - const result = await api.getLayerContentPath(projectPath, layer.name); - if (result.ok) api.revealInExplorer(result.value); - }} - > - Open Folder - - } onClick={onEdit}> - Edit - - - - - -
-
-
- ); -} diff --git a/src/modules/workshop/layers/components/SortableLayerCard.tsx b/src/modules/workshop/layers/components/SortableLayerCard.tsx deleted file mode 100644 index b2205e4..0000000 --- a/src/modules/workshop/layers/components/SortableLayerCard.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - EllipsisVertical, - FolderOpen, - GripVertical, - Pencil, - TextCursorInput, - Trash2, -} from "lucide-react"; -import { type CSSProperties, useRef, useState } from "react"; - -import { IconButton, Menu, Tooltip } from "@/components"; -import { api, type WorkshopLayer, type WorkshopLayerInfo } from "@/lib/tauri"; -import { useRenameLayer } from "@/modules/workshop"; - -import { getStringOverrideCount, WadFilesBadge } from "./LayerCard"; - -interface SortableLayerCardProps { - layer: WorkshopLayer; - projectPath: string; - layerInfo?: WorkshopLayerInfo; - onEdit: () => void; - onDelete: () => void; -} - -export function SortableLayerCard({ - layer, - projectPath, - layerInfo, - onEdit, - onDelete, -}: SortableLayerCardProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: layer.name, - }); - - const style: CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : undefined, - }; - - const stringOverrideCount = getStringOverrideCount(layer); - - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(""); - const inputRef = useRef(null); - const renameLayer = useRenameLayer(); - - function startRename() { - setRenameValue(layer.displayName); - setIsRenaming(true); - requestAnimationFrame(() => inputRef.current?.select()); - } - - function commitRename() { - const trimmed = renameValue.trim(); - if (!trimmed || trimmed === layer.displayName) { - setIsRenaming(false); - return; - } - renameLayer.mutate( - { projectPath, layerName: layer.name, newDisplayName: trimmed }, - { onSettled: () => setIsRenaming(false) }, - ); - } - - return ( -
-
- -
- -
-
-
-
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={commitRename} - onKeyDown={(e) => { - if (e.key === "Enter") commitRename(); - if (e.key === "Escape") setIsRenaming(false); - }} - className="min-w-0 flex-1 rounded border border-surface-600 bg-surface-900 px-2 py-0.5 text-sm font-medium text-surface-100 outline-none focus:border-accent-500" - /> - ) : ( - -

- {layer.displayName} -

-
- )} - - Priority {layer.priority} - - -
- {layer.description && ( -

{layer.description}

- )} - {layerInfo && layerInfo.wadFiles.length === 0 && ( -

- No content yet - add WAD files to this layer's folder to get started. -

- )} -
- - {stringOverrideCount > 0 && ( - - {stringOverrideCount} string override{stringOverrideCount !== 1 ? "s" : ""} - - )} - - - } - /> - } - /> - - - - } - onClick={async () => { - const result = await api.getLayerContentPath(projectPath, layer.name); - if (result.ok) api.revealInExplorer(result.value); - }} - > - Open Folder - - } onClick={startRename}> - Rename - - } onClick={onEdit}> - Edit - - - } - variant="danger" - onClick={onDelete} - > - Delete - - - - - -
-
-
- ); -} diff --git a/src/modules/workshop/layers/components/SortableLayerList.tsx b/src/modules/workshop/layers/components/SortableLayerList.tsx deleted file mode 100644 index cc4a2d4..0000000 --- a/src/modules/workshop/layers/components/SortableLayerList.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - closestCenter, - DndContext, - type DragEndEvent, - DragOverlay, - type DragStartEvent, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { useState } from "react"; - -import type { WorkshopLayer, WorkshopLayerInfo } from "@/lib/tauri"; - -import { LayerCard } from "./LayerCard"; -import { SortableLayerCard } from "./SortableLayerCard"; - -interface SortableLayerListProps { - layers: WorkshopLayer[]; - projectPath: string; - layerInfoMap?: Record; - onReorder: (layerNames: string[]) => void; - onEdit: (layer: WorkshopLayer) => void; - onDelete: (layer: WorkshopLayer) => void; -} - -export function SortableLayerList({ - layers, - projectPath, - layerInfoMap, - onReorder, - onEdit, - onDelete, -}: SortableLayerListProps) { - const [activeId, setActiveId] = useState(null); - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ); - - const items = layers.map((l) => l.name); - const activeDragLayer = activeId ? layers.find((l) => l.name === activeId) : null; - - function handleDragStart(event: DragStartEvent) { - setActiveId(event.active.id as string); - } - - function handleDragEnd(event: DragEndEvent) { - setActiveId(null); - const { active, over } = event; - if (!over || active.id === over.id) return; - - const oldIndex = items.indexOf(active.id as string); - const newIndex = items.indexOf(over.id as string); - onReorder(arrayMove(items, oldIndex, newIndex)); - } - - function handleDragCancel() { - setActiveId(null); - } - - return ( - - -
- {layers.map((layer) => ( - onEdit(layer)} - onDelete={() => onDelete(layer)} - /> - ))} -
-
- - {activeDragLayer && ( -
- -
- )} -
-
- ); -} diff --git a/src/modules/workshop/layers/components/index.ts b/src/modules/workshop/layers/components/index.ts index 7b09d9f..1c465a2 100644 --- a/src/modules/workshop/layers/components/index.ts +++ b/src/modules/workshop/layers/components/index.ts @@ -1,7 +1,3 @@ export { CreateLayerDialog } from "./CreateLayerDialog"; export { DeleteLayerDialog } from "./DeleteLayerDialog"; export { EditLayerDialog } from "./EditLayerDialog"; -export { LayerCard } from "./LayerCard"; -export { LockedLayerCard } from "./LockedLayerCard"; -export { SortableLayerCard } from "./SortableLayerCard"; -export { SortableLayerList } from "./SortableLayerList"; diff --git a/src/modules/workshop/utils/__tests__/contentTree.test.ts b/src/modules/workshop/utils/__tests__/contentTree.test.ts new file mode 100644 index 0000000..88f7c6b --- /dev/null +++ b/src/modules/workshop/utils/__tests__/contentTree.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentEntry } from "@/lib/tauri"; + +import { + allDirPaths, + buildContentTree, + buildDirFileCounts, + type DirNode, + type FileNode, + flattenTree, +} from "../contentTree"; + +function entry(relativePath: string, sizeBytes = 0): ContentEntry { + return { + relativePath, + sizeBytes: BigInt(sizeBytes), + kind: "unknown", + }; +} + +describe("buildContentTree", () => { + it("returns an empty array for no entries", () => { + expect(buildContentTree([])).toEqual([]); + }); + + it("places a single file at the root", () => { + const tree = buildContentTree([entry("readme.md")]); + expect(tree).toHaveLength(1); + expect(tree[0]!.type).toBe("file"); + expect(tree[0]!.name).toBe("readme.md"); + }); + + it("nests files under intermediate directories", () => { + const tree = buildContentTree([entry("assets/textures/skin.dds")]); + expect(tree).toHaveLength(1); + + const assets = tree[0] as DirNode; + expect(assets.type).toBe("dir"); + expect(assets.name).toBe("assets"); + expect(assets.path).toBe("assets"); + + const textures = assets.children[0] as DirNode; + expect(textures.type).toBe("dir"); + expect(textures.path).toBe("assets/textures"); + + const skin = textures.children[0] as FileNode; + expect(skin.type).toBe("file"); + expect(skin.name).toBe("skin.dds"); + expect(skin.entry.relativePath).toBe("assets/textures/skin.dds"); + }); + + it("merges sibling files into the same directory node", () => { + const tree = buildContentTree([ + entry("assets/a.bin"), + entry("assets/b.bin"), + entry("assets/sub/c.bin"), + ]); + expect(tree).toHaveLength(1); + const assets = tree[0] as DirNode; + expect(assets.children).toHaveLength(3); + const names = assets.children.map((c) => c.name); + expect(names).toEqual(["sub", "a.bin", "b.bin"]); + }); + + it("sorts directories before files within a directory, each group alphabetically", () => { + const tree = buildContentTree([ + entry("z-file.bin"), + entry("a-file.bin"), + entry("m-dir/x.bin"), + entry("a-dir/x.bin"), + ]); + const names = tree.map((c) => c.name); + expect(names).toEqual(["a-dir", "m-dir", "a-file.bin", "z-file.bin"]); + }); + + it("ignores leading or duplicate slashes defensively", () => { + const tree = buildContentTree([entry("//odd///path.bin")]); + const odd = tree[0] as DirNode; + expect(odd.name).toBe("odd"); + const file = odd.children[0] as FileNode; + expect(file.name).toBe("path.bin"); + }); +}); + +describe("flattenTree", () => { + it("returns only top-level rows when nothing is expanded", () => { + const tree = buildContentTree([entry("a/file.bin"), entry("b/nested/x.bin"), entry("c.bin")]); + const rows = flattenTree(tree, new Set()); + expect(rows.map((r) => r.node.name)).toEqual(["a", "b", "c.bin"]); + expect(rows.every((r) => r.depth === 0)).toBe(true); + }); + + it("includes children of expanded directories and carries depth", () => { + const tree = buildContentTree([entry("a/file.bin"), entry("a/sub/deep.bin")]); + const expanded = allDirPaths(tree); // fully expanded + const rows = flattenTree(tree, expanded); + expect(rows.map((r) => `${r.depth}:${r.node.name}`)).toEqual([ + "0:a", + "1:sub", + "2:deep.bin", + "1:file.bin", + ]); + }); + + it("stops descending past collapsed directories", () => { + const tree = buildContentTree([entry("a/sub/deep.bin"), entry("a/top.bin")]); + // expand only `a`, not `a/sub` + const rows = flattenTree(tree, new Set(["a"])); + expect(rows.map((r) => `${r.depth}:${r.node.name}`)).toEqual(["0:a", "1:sub", "1:top.bin"]); + }); +}); + +describe("allDirPaths", () => { + it("collects every directory path", () => { + const tree = buildContentTree([entry("a/x.bin"), entry("b/nested/y.bin"), entry("c.bin")]); + const paths = allDirPaths(tree); + expect(paths).toEqual(new Set(["a", "b", "b/nested"])); + }); +}); + +describe("buildDirFileCounts", () => { + it("counts files recursively per directory", () => { + const tree = buildContentTree([ + entry("a/x.bin"), + entry("a/y.bin"), + entry("a/sub/z.bin"), + entry("a/sub/deep/q.bin"), + entry("b/top.bin"), + ]); + const counts = buildDirFileCounts(tree); + expect(counts.get("a")).toBe(4); + expect(counts.get("a/sub")).toBe(2); + expect(counts.get("a/sub/deep")).toBe(1); + expect(counts.get("b")).toBe(1); + }); + + it("returns an empty map for an empty tree", () => { + expect(buildDirFileCounts([])).toEqual(new Map()); + }); +}); diff --git a/src/modules/workshop/utils/contentTree.ts b/src/modules/workshop/utils/contentTree.ts new file mode 100644 index 0000000..af44cc9 --- /dev/null +++ b/src/modules/workshop/utils/contentTree.ts @@ -0,0 +1,140 @@ +import type { ContentEntry } from "@/lib/tauri"; + +export type ContentTreeNode = DirNode | FileNode; + +export interface DirNode { + readonly type: "dir"; + readonly name: string; + /** Path relative to the layer root, POSIX-style, no trailing slash. */ + readonly path: string; + readonly children: ContentTreeNode[]; +} + +export interface FileNode { + readonly type: "file"; + readonly name: string; + readonly entry: ContentEntry; +} + +/** + * Group a layer's flat file entries into a nested directory/file tree. + * + * Entries keep the order they were given within each directory — the backend + * already sorts by relative path, so the tree inherits that ordering. Within a + * single directory, children are then sorted directories-first, each group + * alphabetically, to match typical file-tree expectations. + */ +export function buildContentTree(entries: readonly ContentEntry[]): ContentTreeNode[] { + const root: DirNode = { type: "dir", name: "", path: "", children: [] }; + + for (const entry of entries) { + const segments = entry.relativePath.split("/").filter((s) => s.length > 0); + if (segments.length === 0) continue; + + let cursor: DirNode = root; + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]!; + const childPath = cursor.path ? `${cursor.path}/${segment}` : segment; + + let next = cursor.children.find((c): c is DirNode => c.type === "dir" && c.name === segment); + if (!next) { + next = { type: "dir", name: segment, path: childPath, children: [] }; + (cursor.children as ContentTreeNode[]).push(next); + } + cursor = next; + } + + const fileName = segments[segments.length - 1]!; + (cursor.children as ContentTreeNode[]).push({ + type: "file", + name: fileName, + entry, + }); + } + + sortRecursive(root); + return root.children; +} + +function sortRecursive(dir: DirNode): void { + (dir.children as ContentTreeNode[]).sort(compareNodes); + for (const child of dir.children) { + if (child.type === "dir") sortRecursive(child); + } +} + +function compareNodes(a: ContentTreeNode, b: ContentTreeNode): number { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); +} + +export interface FlatTreeRow { + readonly node: ContentTreeNode; + readonly depth: number; +} + +/** + * Walk a tree and produce the linear list of rows that should currently be + * rendered, respecting expand/collapse state. A directory is always included; + * its children are only included when its path is in `expanded`. + * + * Feeds `@tanstack/react-virtual` so we only render what's visible regardless + * of how many files the project contains. + */ +export function flattenTree( + tree: readonly ContentTreeNode[], + expanded: ReadonlySet, +): FlatTreeRow[] { + const out: FlatTreeRow[] = []; + const walk = (nodes: readonly ContentTreeNode[], depth: number): void => { + for (const node of nodes) { + out.push({ node, depth }); + if (node.type === "dir" && expanded.has(node.path)) { + walk(node.children, depth + 1); + } + } + }; + walk(tree, 0); + return out; +} + +/** + * Collect the paths of every directory node in a tree — useful for seeding + * the expanded-set so the tree renders fully expanded by default. + */ +export function allDirPaths(tree: readonly ContentTreeNode[]): Set { + const set = new Set(); + const walk = (nodes: readonly ContentTreeNode[]): void => { + for (const node of nodes) { + if (node.type === "dir") { + set.add(node.path); + walk(node.children); + } + } + }; + walk(tree); + return set; +} + +/** + * Precompute the recursive file count for every directory. Rendered rows read + * this in O(1) instead of re-walking the subtree on every paint. + */ +export function buildDirFileCounts(tree: readonly ContentTreeNode[]): Map { + const counts = new Map(); + const walk = (nodes: readonly ContentTreeNode[]): number => { + let total = 0; + for (const node of nodes) { + if (node.type === "file") { + total += 1; + } else { + const subCount = walk(node.children); + counts.set(node.path, subCount); + total += subCount; + } + } + return total; + }; + walk(tree); + return counts; +} diff --git a/src/modules/workshop/utils/fileKindIcon.ts b/src/modules/workshop/utils/fileKindIcon.ts new file mode 100644 index 0000000..a9a19c9 --- /dev/null +++ b/src/modules/workshop/utils/fileKindIcon.ts @@ -0,0 +1,71 @@ +import { + Box, + File, + FileCode2, + FileText, + Image, + type LucideIcon, + PersonStanding, + Sun, + Volume2, +} from "lucide-react"; + +import type { WorkshopFileKind } from "@/lib/tauri"; + +export interface FileKindDescriptor { + /** Lucide icon component rendered in each file row. */ + readonly icon: LucideIcon; + /** Human-readable name shown on hover. */ + readonly label: string; + /** CSS custom property name used to tint the icon. */ + readonly tintToken: string; +} + +const IMAGE_TINT = "--accent-400"; +const STRUCTURE_TINT = "--surface-300"; +const DATA_TINT = "--surface-400"; +const UNKNOWN_TINT = "--surface-500"; + +export const FILE_KIND_DESCRIPTORS = { + // Texture / image + png: { icon: Image, label: "PNG Image", tintToken: IMAGE_TINT }, + jpeg: { icon: Image, label: "JPEG Image", tintToken: IMAGE_TINT }, + tga: { icon: Image, label: "TGA Image", tintToken: IMAGE_TINT }, + svg: { icon: Image, label: "SVG Image", tintToken: IMAGE_TINT }, + texture: { icon: Image, label: "Riot Texture", tintToken: IMAGE_TINT }, + texture_dds: { icon: Image, label: "DDS Texture", tintToken: IMAGE_TINT }, + + // Mesh + simple_skin: { icon: Box, label: "Simple Skin Mesh", tintToken: STRUCTURE_TINT }, + static_mesh_ascii: { icon: Box, label: "Static Mesh (ASCII)", tintToken: STRUCTURE_TINT }, + static_mesh_binary: { icon: Box, label: "Static Mesh (Binary)", tintToken: STRUCTURE_TINT }, + map_geometry: { icon: Box, label: "Map Geometry", tintToken: STRUCTURE_TINT }, + world_geometry: { icon: Box, label: "World Geometry", tintToken: STRUCTURE_TINT }, + + // Animation / rig + animation: { icon: PersonStanding, label: "Animation", tintToken: STRUCTURE_TINT }, + skeleton: { icon: PersonStanding, label: "Skeleton", tintToken: STRUCTURE_TINT }, + + // Property data + property_bin: { icon: FileCode2, label: "Property Bin", tintToken: DATA_TINT }, + property_bin_override: { icon: FileCode2, label: "Property Bin Override", tintToken: DATA_TINT }, + preload: { icon: FileCode2, label: "Preload", tintToken: DATA_TINT }, + + // Text / strings + riot_string_table: { icon: FileText, label: "Riot String Table", tintToken: DATA_TINT }, + lua_obj: { icon: FileText, label: "Compiled Lua", tintToken: DATA_TINT }, + + // Audio + wwise_bank: { icon: Volume2, label: "Wwise Bank", tintToken: STRUCTURE_TINT }, + wwise_package: { icon: Volume2, label: "Wwise Package", tintToken: STRUCTURE_TINT }, + + // Light data + light_grid: { icon: Sun, label: "Light Grid", tintToken: STRUCTURE_TINT }, + + // Fallback + unknown: { icon: File, label: "Unknown file", tintToken: UNKNOWN_TINT }, +} as const satisfies Record; + +export function describeFileKind(kind: WorkshopFileKind): FileKindDescriptor { + return FILE_KIND_DESCRIPTORS[kind]; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5f6dc6a..89a498a 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,7 +17,7 @@ import { Route as WorkshopProjectNameRouteImport } from './routes/workshop/$proj import { Route as FolderFolderIdRouteImport } from './routes/folder.$folderId' import { Route as WorkshopProjectNameIndexRouteImport } from './routes/workshop/$projectName/index' import { Route as WorkshopProjectNameStringsRouteImport } from './routes/workshop/$projectName/strings' -import { Route as WorkshopProjectNameLayersRouteImport } from './routes/workshop/$projectName/layers' +import { Route as WorkshopProjectNameContentRouteImport } from './routes/workshop/$projectName/content' const WorkshopRoute = WorkshopRouteImport.update({ id: '/workshop', @@ -61,10 +61,10 @@ const WorkshopProjectNameStringsRoute = path: '/strings', getParentRoute: () => WorkshopProjectNameRoute, } as any) -const WorkshopProjectNameLayersRoute = - WorkshopProjectNameLayersRouteImport.update({ - id: '/layers', - path: '/layers', +const WorkshopProjectNameContentRoute = + WorkshopProjectNameContentRouteImport.update({ + id: '/content', + path: '/content', getParentRoute: () => WorkshopProjectNameRoute, } as any) @@ -75,7 +75,7 @@ export interface FileRoutesByFullPath { '/folder/$folderId': typeof FolderFolderIdRoute '/workshop/$projectName': typeof WorkshopProjectNameRouteWithChildren '/workshop/': typeof WorkshopIndexRoute - '/workshop/$projectName/layers': typeof WorkshopProjectNameLayersRoute + '/workshop/$projectName/content': typeof WorkshopProjectNameContentRoute '/workshop/$projectName/strings': typeof WorkshopProjectNameStringsRoute '/workshop/$projectName/': typeof WorkshopProjectNameIndexRoute } @@ -84,7 +84,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRoute '/folder/$folderId': typeof FolderFolderIdRoute '/workshop': typeof WorkshopIndexRoute - '/workshop/$projectName/layers': typeof WorkshopProjectNameLayersRoute + '/workshop/$projectName/content': typeof WorkshopProjectNameContentRoute '/workshop/$projectName/strings': typeof WorkshopProjectNameStringsRoute '/workshop/$projectName': typeof WorkshopProjectNameIndexRoute } @@ -96,7 +96,7 @@ export interface FileRoutesById { '/folder/$folderId': typeof FolderFolderIdRoute '/workshop/$projectName': typeof WorkshopProjectNameRouteWithChildren '/workshop/': typeof WorkshopIndexRoute - '/workshop/$projectName/layers': typeof WorkshopProjectNameLayersRoute + '/workshop/$projectName/content': typeof WorkshopProjectNameContentRoute '/workshop/$projectName/strings': typeof WorkshopProjectNameStringsRoute '/workshop/$projectName/': typeof WorkshopProjectNameIndexRoute } @@ -109,7 +109,7 @@ export interface FileRouteTypes { | '/folder/$folderId' | '/workshop/$projectName' | '/workshop/' - | '/workshop/$projectName/layers' + | '/workshop/$projectName/content' | '/workshop/$projectName/strings' | '/workshop/$projectName/' fileRoutesByTo: FileRoutesByTo @@ -118,7 +118,7 @@ export interface FileRouteTypes { | '/settings' | '/folder/$folderId' | '/workshop' - | '/workshop/$projectName/layers' + | '/workshop/$projectName/content' | '/workshop/$projectName/strings' | '/workshop/$projectName' id: @@ -129,7 +129,7 @@ export interface FileRouteTypes { | '/folder/$folderId' | '/workshop/$projectName' | '/workshop/' - | '/workshop/$projectName/layers' + | '/workshop/$projectName/content' | '/workshop/$projectName/strings' | '/workshop/$projectName/' fileRoutesById: FileRoutesById @@ -199,24 +199,24 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WorkshopProjectNameStringsRouteImport parentRoute: typeof WorkshopProjectNameRoute } - '/workshop/$projectName/layers': { - id: '/workshop/$projectName/layers' - path: '/layers' - fullPath: '/workshop/$projectName/layers' - preLoaderRoute: typeof WorkshopProjectNameLayersRouteImport + '/workshop/$projectName/content': { + id: '/workshop/$projectName/content' + path: '/content' + fullPath: '/workshop/$projectName/content' + preLoaderRoute: typeof WorkshopProjectNameContentRouteImport parentRoute: typeof WorkshopProjectNameRoute } } } interface WorkshopProjectNameRouteChildren { - WorkshopProjectNameLayersRoute: typeof WorkshopProjectNameLayersRoute + WorkshopProjectNameContentRoute: typeof WorkshopProjectNameContentRoute WorkshopProjectNameStringsRoute: typeof WorkshopProjectNameStringsRoute WorkshopProjectNameIndexRoute: typeof WorkshopProjectNameIndexRoute } const WorkshopProjectNameRouteChildren: WorkshopProjectNameRouteChildren = { - WorkshopProjectNameLayersRoute: WorkshopProjectNameLayersRoute, + WorkshopProjectNameContentRoute: WorkshopProjectNameContentRoute, WorkshopProjectNameStringsRoute: WorkshopProjectNameStringsRoute, WorkshopProjectNameIndexRoute: WorkshopProjectNameIndexRoute, } diff --git a/src/routes/workshop/$projectName.tsx b/src/routes/workshop/$projectName.tsx index 9c24a31..c1ad41f 100644 --- a/src/routes/workshop/$projectName.tsx +++ b/src/routes/workshop/$projectName.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link, Outlet } from "@tanstack/react-router"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, FolderTree, Globe, Package } from "lucide-react"; import { Button, NavTabs } from "@/components"; import { @@ -38,10 +38,27 @@ function ProjectDetailLayout() { ); } + const tabIconClass = "h-3.5 w-3.5"; const tabs = [ - { to: "/workshop/$projectName", params: { projectName }, label: "Overview", exact: true }, - { to: "/workshop/$projectName/strings", params: { projectName }, label: "Strings" }, - { to: "/workshop/$projectName/layers", params: { projectName }, label: "Layers" }, + { + to: "/workshop/$projectName", + params: { projectName }, + label: "Overview", + icon: , + exact: true, + }, + { + to: "/workshop/$projectName/content", + params: { projectName }, + label: "Content", + icon: , + }, + { + to: "/workshop/$projectName/strings", + params: { projectName }, + label: "Strings", + icon: , + }, ]; return ( @@ -51,7 +68,7 @@ function ProjectDetailLayout() { -
+
diff --git a/src/routes/workshop/$projectName/content.tsx b/src/routes/workshop/$projectName/content.tsx new file mode 100644 index 0000000..8420ce2 --- /dev/null +++ b/src/routes/workshop/$projectName/content.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ContentBrowser, useProjectContext } from "@/modules/workshop"; + +export const Route = createFileRoute("/workshop/$projectName/content")({ + component: ProjectContent, +}); + +function ProjectContent() { + const project = useProjectContext(); + + return ( +
+ +
+ ); +} diff --git a/src/routes/workshop/$projectName/index.tsx b/src/routes/workshop/$projectName/index.tsx index 88bb7de..dfa7fd7 100644 --- a/src/routes/workshop/$projectName/index.tsx +++ b/src/routes/workshop/$projectName/index.tsx @@ -66,7 +66,7 @@ function ProjectOverview() { }); return ( -
+
}>
@@ -152,22 +152,26 @@ function ProjectOverview() {
- +
+ - setAuthors((prev) => appendAuthor(prev, initial))} - onRemove={(i) => setAuthors((prev) => removeAuthorAt(prev, i))} - onUpdate={(i, field, value) => setAuthors((prev) => updateAuthorAt(prev, i, field, value))} - /> + setAuthors((prev) => appendAuthor(prev, initial))} + onRemove={(i) => setAuthors((prev) => removeAuthorAt(prev, i))} + onUpdate={(i, field, value) => + setAuthors((prev) => updateAuthorAt(prev, i, field, value)) + } + /> +
-
+
-
- - {allLayers.length === 0 ? ( -
- -

No layers

-

This project has no layers configured.

-
- ) : ( -
- {baseLayer && ( - setEditLayer(baseLayer)} - /> - )} - - {sortableLayers.length > 0 && ( - - reorderLayers.mutate({ projectPath: project.path, layerNames: names }) - } - onEdit={setEditLayer} - onDelete={setDeleteTarget} - /> - )} -
- )} - - setCreateOpen(false)} - onSubmit={handleCreateSubmit} - isPending={createLayer.isPending} - existingNames={allLayers.map((l) => l.name)} - /> - - setEditLayer(null)} - projectPath={project.path} - /> - - setDeleteTarget(null)} - onConfirm={handleDeleteConfirm} - isPending={deleteLayer.isPending} - /> -
- ); -} diff --git a/src/routes/workshop/$projectName/strings.tsx b/src/routes/workshop/$projectName/strings.tsx index bfee38e..d858efb 100644 --- a/src/routes/workshop/$projectName/strings.tsx +++ b/src/routes/workshop/$projectName/strings.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { Globe, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; -import { Button, Field, IconButton, Tabs, Tooltip, useToast } from "@/components"; +import { AlertBox, Button, Field, IconButton, Tabs, Tooltip, useToast } from "@/components"; import type { WorkshopLayer } from "@/lib/tauri"; import { useProjectContext, useSaveStringOverrides } from "@/modules/workshop"; @@ -165,7 +165,7 @@ function ProjectStrings() { const overrideCount = entries.length; return ( -
+

String Overrides

@@ -173,6 +173,27 @@ function ProjectStrings() {

+ +

+ Once this ships, overrides you configure here will be applied dynamically during overlay + building — no repacking the game’s stringtable WAD, so your mod won’t break + every patch. Right now the feature is blocked by work in the underlying library; you can + edit entries but they won’t take effect in-game. +

+

+ Track progress on{" "} + + issue #123 + + . +

+
+ {/* Layer tabs */} {layers.length > 1 && ( diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts new file mode 100644 index 0000000..a28088b --- /dev/null +++ b/src/utils/formatBytes.ts @@ -0,0 +1,11 @@ +/** + * Format a byte count as a short, human-readable string ("1.2 MB", "48 KB"). + * Uses base-1024 units and one decimal place above the byte tier. + */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "0 B"; + if (bytes < 1024) return `${Math.round(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index f5b8f1f..b81938c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./errors"; +export * from "./formatBytes"; export * from "./query"; export * from "./result"; export * from "./slug";