diff --git a/DESIGN.md b/DESIGN.md index abe4bab8b..f9450fba3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -280,6 +280,8 @@ Quiet, rectangular, decisive. ### Commit Row (signature) The graph rail, then avatar (40px circle), then a header line: author name (600), mono handle, a middle dot, mono relative time, and the 7-character short hash pushed right (from sm). The body follows at 15px, pre-wrapped. Under the body, a quiet star: a 16px outline icon in Muted Ink, filled in Commit Blue once starred, with the count in mono 13px beside it when the count is at least 1. It is hidden on commits the viewer cannot star. The toggle updates immediately and rolls back if the request fails, without reloading the log. Your own rows reveal a trash icon on hover/focus (always visible on touch). Copy link and Report sit at the right of the action row and reveal the same way, a reply's only while that reply is hovered; on phones they live in a "โ€ฆ" menu. Deleting is inline: a soft red strip asks "Delete this commit? There's no undo." with Cancel and Delete; confirming swaps the body for a mono `rm {hash} โ†’ /dev/null` status, then the row collapses (height and opacity, 300ms after a 400ms hold). +**Source badge:** When present, the creating client appears immediately after the relative time as a quiet, non-interactive 4px-cornered Muted Ink label in 12px Geist Mono. The frontend renders the backend-provided source verbatim; current values include `web`, `cli`, `tui`, `ios`, `android`, `macos`, `windows`, and `linux`. Hovering it opens the standard metadata tooltip with the literal copy `Committed via {source}`; the label remains available as its accessible name. Missing attribution shows no label. Thread position and moderation labels follow it. Compact replies, quotes, search results, and scheduled commits use the same label at 11px; headers wrap metadata below the author on narrow widths rather than truncate a known source. + ### Graph Rail (signature) A 14px circle (16px on the landing page) centered on a 2px vertical line. The segment above a node takes the previous author's lane; the segment below takes this author's. The first commit hangs from a Rail Gray segment beneath the staged composer. The end of history is a hollow **root** node with no tail and a muted line ("That's the root commit. There's nothing before it."). A commit you made in the last 15 seconds animates in with `node-in` (scale 0 to 1.25 to 1 with a lane-colored pulse, 700ms, expo-out); landing-page segments draw downward with `rail-draw`, staggered 140ms. Both respect reduced motion. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 9bc21f6ba..4789bb22f 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -6,6 +6,7 @@ use App\Dto\NewPoll; use App\Dto\ThreadEngagement; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Jobs\CheckUserLevel; use App\Jobs\EmbedCommit; @@ -55,7 +56,7 @@ public function __construct( * @param array $imageDescriptions * @param array $replyRecipients */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null, ?CommitSource $source = null): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -63,7 +64,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization, $source): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -73,6 +74,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'user_id' => $user->id, 'author_id' => $organization->id ?? $user->id, 'body' => $this->emojiShortcodes->replace($body), + 'source' => $source, 'mentions' => [...$replyRecipients, ...$mentions], 'parent_id' => $parent?->id, 'root_id' => $parent?->threadRootId(), diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 289e5abb1..ea4cb2b52 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -183,13 +183,15 @@ public function handle(User $user): array } /** + * Streams every commit the user owns, including deleted ones, with its recorded source and cached translations. + * * @return Generator> */ private function commits(User $user): Generator { $commits = $user->commits() ->withTrashed() - ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) + ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'source', 'status', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) ->with('images:id,commit_id') ->lazyById(500); @@ -204,6 +206,7 @@ private function commits(User $user): Generator 'parent_id' => $commit->parent_id, 'quoted_commit_id' => $commit->quoted_commit_id, 'body' => $commit->body, + 'source' => $commit->source?->value, 'cached_translations' => $cached[$translations->key($commit)], 'status' => $commit->status->value, 'pasteis_count' => $commit->pasteis_count, @@ -238,6 +241,7 @@ private function organizationCommits(User $user): Generator 'parent_id' => $commit->parent_id, 'quoted_commit_id' => $commit->quoted_commit_id, 'body' => $commit->body, + 'source' => $commit->source?->value, 'status' => $commit->status->value, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), diff --git a/app/Actions/CreateScheduledCommit.php b/app/Actions/CreateScheduledCommit.php index 6d00ed8d5..99e922555 100644 --- a/app/Actions/CreateScheduledCommit.php +++ b/app/Actions/CreateScheduledCommit.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Dto\NewPoll; +use App\Enums\CommitSource; use App\Models\Organization; use App\Models\ScheduledCommit; use App\Models\User; @@ -27,7 +28,7 @@ public function __construct(private CreateImageFiles $createImageFiles, private * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null): ScheduledCommit + public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null, ?CommitSource $source = null): ScheduledCommit { $body = mb_trim($body); @@ -35,6 +36,7 @@ public function handle(User $user, string $body, CarbonImmutable $scheduledAt, a 'user_id' => $user->id, 'author_id' => $organization?->id, 'body' => $body, + 'source' => $source, 'poll' => $poll instanceof NewPoll ? ['options' => $poll->options, 'duration' => $poll->duration->value] : null, 'images' => $images === [] ? null : $this->createImageFiles->handle($images, $imageDescriptions), 'link_preview_url' => match ($linkPreview) { diff --git a/app/Actions/UpdateScheduledCommitSubmittedAt.php b/app/Actions/UpdateScheduledCommitSubmittedAt.php index d3f0612e9..2fec18f02 100644 --- a/app/Actions/UpdateScheduledCommitSubmittedAt.php +++ b/app/Actions/UpdateScheduledCommitSubmittedAt.php @@ -57,7 +57,7 @@ public function handle(string $id, bool $early = false): bool $scheduled = ScheduledCommit::query()->with(['user', 'author'])->findOrFail($id); assert(is_string($scheduled->body)); $poll = $scheduled->poll === null ? null : new NewPoll($scheduled->poll['options'], PollDuration::from($scheduled->poll['duration'])); - $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization); + $commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization, source: $scheduled->source); $commit->images()->createMany($scheduled->images ?? []); $scheduled->forceFill(['commit_id' => $commit->id, 'body' => null, 'poll' => null, 'images' => null, 'link_preview_url' => null])->save(); diff --git a/app/Enums/CommitSource.php b/app/Enums/CommitSource.php new file mode 100644 index 000000000..a70b3287a --- /dev/null +++ b/app/Enums/CommitSource.php @@ -0,0 +1,17 @@ +handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients(), organization: $request->organization()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients(), organization: $request->organization(), source: $sources->from($request)); return CommitResource::make($page->get($commit, $user)) ->response() diff --git a/app/Http/Controllers/Api/ScheduledCommitController.php b/app/Http/Controllers/Api/ScheduledCommitController.php index ec9b1fe5d..125b3ecae 100644 --- a/app/Http/Controllers/Api/ScheduledCommitController.php +++ b/app/Http/Controllers/Api/ScheduledCommitController.php @@ -12,6 +12,7 @@ use App\Models\ScheduledCommit; use App\Models\User; use App\Queries\ScheduledCommitsQuery; +use App\Services\CommitSourceResolver; use Carbon\CarbonImmutable; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\JsonResponse; @@ -31,9 +32,9 @@ public function index(#[CurrentUser] User $user, ScheduledCommitsQuery $schedule /** * Only the user sees a scheduled commit until it is submitted. */ - public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): JsonResponse + public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action, CommitSourceResolver $sources): JsonResponse { - $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); + $scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), $sources->from($request)); return ScheduledCommitResource::make($scheduled) ->response() diff --git a/app/Http/Controllers/Cli/CommitController.php b/app/Http/Controllers/Cli/CommitController.php index a2e10a1d1..a4c14c83e 100644 --- a/app/Http/Controllers/Cli/CommitController.php +++ b/app/Http/Controllers/Cli/CommitController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Cli; use App\Actions\CreateCommit; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCliCommitRequest; use App\Models\User; @@ -13,9 +14,12 @@ final readonly class CommitController { + /** + * Creates a CLI commit and returns its URL, noting when it is awaiting moderation. + */ public function store(CreateCliCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): Response { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images(), source: CommitSource::Cli); $url = route('commit.show', [$user, $commit]); diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index ecfe35a1e..bafb88a2b 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -7,6 +7,7 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; use App\Dto\ThreadContinuation; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitRequest; use App\Http\Requests\DeleteCommitRequest; @@ -63,12 +64,15 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs ])->withViewData(['preview' => $preview]); } + /** + * Creates a web commit and flashes any achievements it unlocks for its author. + */ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, UserAchievementsQuery $achievements): RedirectResponse { $before = $achievements->unlockedBadgeNames($user); $organization = $request->organization(); - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), organization: $organization); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), organization: $organization, source: CommitSource::Web); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index 9a5b63926..0067edd1f 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -6,6 +6,7 @@ use App\Actions\CreateCommit; use App\Actions\DeleteCommit; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitReplyRequest; use App\Http\Requests\DeleteCommitReplyRequest; @@ -28,7 +29,7 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse { $organization = $request->organization(); - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients(), organization: $organization); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients(), organization: $organization, source: CommitSource::Web); $parent = $thread->get($commit, $user); return response()->json([ diff --git a/app/Http/Controllers/ScheduledCommitController.php b/app/Http/Controllers/ScheduledCommitController.php index 84c50a53d..71df7339c 100644 --- a/app/Http/Controllers/ScheduledCommitController.php +++ b/app/Http/Controllers/ScheduledCommitController.php @@ -6,6 +6,7 @@ use App\Actions\CreateScheduledCommit; use App\Actions\DeleteScheduledCommit; +use App\Enums\CommitSource; use App\Http\Requests\CreateScheduledCommitRequest; use App\Http\Requests\DeleteScheduledCommitRequest; use App\Http\Resources\ScheduledCommitResource; @@ -30,9 +31,12 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm ]); } + /** + * Schedules a web commit and marks the session so its queue refreshes. + */ public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): RedirectResponse { - $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization()); + $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization(), CommitSource::Web); Inertia::flash('scheduled', true); Inertia::flash('toast', ['type' => 'success', 'message' => 'Commit scheduled. Only you can see it until it is submitted.']); diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 31dc2a058..d0089d50a 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -37,6 +37,8 @@ final class CommitResource extends JsonResource { /** + * Serializes public commits, signed-only fields, and deleted thread tombstones without exposing their body. + * * @return array */ public function toArray(Request $request): array @@ -56,6 +58,7 @@ public function toArray(Request $request): array 'id' => $this->id, 'short_hash' => mb_substr(str_replace('-', '', $this->id), -7), 'body' => $this->body, + 'source' => $this->source?->value, 'parent_id' => $isSigned ? $this->parent_id : $this->parent?->id, 'html_url' => route('commit.show', [$this->author, $this->resource]), 'created_at' => $this->created_at->toIso8601ZuluString(), diff --git a/app/Http/Resources/ScheduledCommitResource.php b/app/Http/Resources/ScheduledCommitResource.php index c8f1346be..94fad3aa2 100644 --- a/app/Http/Resources/ScheduledCommitResource.php +++ b/app/Http/Resources/ScheduledCommitResource.php @@ -19,6 +19,8 @@ final class ScheduledCommitResource extends JsonResource { /** + * Serializes an owner's scheduled commit, withholding its body after submission or deletion. + * * @return array */ public function toArray(Request $request): array @@ -26,6 +28,7 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'author' => $this->author === null ? null : ProfileResource::make($this->author)->resolve($request), + 'source' => $this->source?->value, ...($this->submitted_at === null && $this->body !== null ? ['body' => $this->body, ...resolve(CommitBodies::class)->segments($this->body)] : ['body' => null, 'body_segments' => [], 'preview_segments' => null]), diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 805af50de..edec92768 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -4,12 +4,14 @@ namespace App\Models; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Queries\CommitBuilder; use App\Services\CommitBodies; use App\Services\Mentions; use Carbon\CarbonInterface; use Database\Factories\CommitFactory; +use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder; use Illuminate\Database\Eloquent\Casts\AsVector; @@ -31,6 +33,7 @@ * @property-read string|null $root_id * @property-read float $hot_score * @property-read string $body + * @property-read CommitSource|null $source * @property-read array|null $mentions * @property-read array|null $organization_mentions * @property-read CommitStatus $status @@ -70,6 +73,20 @@ 'embedding', 'headlines_classified_at', ])] +#[Fillable([ + 'user_id', + 'author_id', + 'body', + 'source', + 'mentions', + 'parent_id', + 'root_id', + 'status', + 'link_preview_id', + 'quoted_commit_id', + 'hot_score', + 'created_at', +])] #[UseEloquentBuilder(CommitBuilder::class)] final class Commit extends Model { @@ -124,6 +141,7 @@ public function casts(): array 'hot_score' => 'float', 'pasteis_count' => 'integer', 'body' => 'string', + 'source' => CommitSource::class, 'mentions' => 'array', 'organization_mentions' => 'array', 'status' => CommitStatus::class, diff --git a/app/Models/ScheduledCommit.php b/app/Models/ScheduledCommit.php index 5bd7170ce..d2e8db7e5 100644 --- a/app/Models/ScheduledCommit.php +++ b/app/Models/ScheduledCommit.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\CommitSource; use Carbon\CarbonInterface; use Database\Factories\ScheduledCommitFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -18,6 +19,7 @@ * @property-read string|null $author_id * @property-read string|null $commit_id * @property-read string|null $body + * @property-read CommitSource|null $source * @property-read array{options: list, duration: int}|null $poll * @property-read list|null $images * @property-read string|null $link_preview_url @@ -28,7 +30,7 @@ * @property-read Profile|null $author * @property-read Commit|null $commit */ -#[Fillable(['user_id', 'author_id', 'body', 'poll', 'images', 'link_preview_url', 'scheduled_at'])] +#[Fillable(['user_id', 'author_id', 'body', 'source', 'poll', 'images', 'link_preview_url', 'scheduled_at'])] final class ScheduledCommit extends Model { /** @use HasFactory */ @@ -42,6 +44,7 @@ public function casts(): array return [ 'poll' => 'array', 'images' => 'array', + 'source' => CommitSource::class, 'scheduled_at' => 'immutable_datetime', 'submitted_at' => 'immutable_datetime', 'canceled_at' => 'immutable_datetime', diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 9ce30e753..e718de7c9 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -29,7 +29,7 @@ final class CommitBuilder extends Builder * Every column but the embedding, in the table's order. */ private const array COLUMNS_BUT_EMBEDDING = [ - 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', + 'id', 'user_id', 'body', 'source', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', 'author_id', 'headlines_classified_at', ]; diff --git a/app/Services/CommitSourceResolver.php b/app/Services/CommitSourceResolver.php new file mode 100644 index 000000000..86b5c2520 --- /dev/null +++ b/app/Services/CommitSourceResolver.php @@ -0,0 +1,29 @@ +header('X-Devnull-Client', '')); + + if ($source instanceof CommitSource) { + return $source; + } + + return match (true) { + preg_match('/^DevNull-Mac\/[^\s]+$/', $request->userAgent() ?? '') === 1 => CommitSource::Macos, + preg_match('/^devnull-tui\/[^\s]+$/', $request->userAgent() ?? '') === 1 => CommitSource::Tui, + default => null, + }; + } +} diff --git a/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php new file mode 100644 index 000000000..eda75c88d --- /dev/null +++ b/database/migrations/2026_10_30_120000_add_source_to_commits_and_scheduled_commits_tables.php @@ -0,0 +1,24 @@ +string('source', 16)->nullable()->after('body'); + }); + + Schema::table('scheduled_commits', function (Blueprint $table): void { + $table->string('source', 16)->nullable()->after('body'); + }); + } +}; diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 000353a52..23e4e3e75 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -10,6 +10,7 @@ import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; import { CommitQuoteButton } from '@/components/commit-quote-button'; import { CommitSecondaryActions } from '@/components/commit-secondary-actions'; +import { CommitSourceBadge } from '@/components/commit-source-badge'; import { CommitStarButton } from '@/components/commit-star-button'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { @@ -347,7 +348,7 @@ export function CommitCard({ })} >
-
+
+ + diff --git a/resources/js/components/commit-source-badge.tsx b/resources/js/components/commit-source-badge.tsx new file mode 100644 index 000000000..cabc71e72 --- /dev/null +++ b/resources/js/components/commit-source-badge.tsx @@ -0,0 +1,37 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +/** A quiet, non-interactive record of the client that created a commit. */ +export function CommitSourceBadge({ + source, + size = 'default', +}: { + source?: string | null; + size?: 'default' | 'small'; +}) { + if (source === null || source === undefined) { + return null; + } + + return ( + + + + Committed via + {source} + + + Committed via {source} + + ); +} diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index 18832c276..cde163bba 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -34,6 +34,7 @@ import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; import { CommitSecondaryActions } from '@/components/commit-secondary-actions'; +import { CommitSourceBadge } from '@/components/commit-source-badge'; import { CommitStarButton } from '@/components/commit-star-button'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { useCommitTranslation } from '@/components/commit-translation'; @@ -1198,7 +1199,7 @@ function CommitReply({ 'opacity-50': wasDeleted, })} > -
+
+ {isOwn && ( diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index cba9565d6..ecf8ecf36 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -5,6 +5,7 @@ import { AuthorAvatar } from '@/components/author-avatar'; import { CommitBodyExcerpt } from '@/components/commit-body'; import { CommitImages } from '@/components/commit-images'; import { CommitPoll } from '@/components/commit-poll'; +import { CommitSourceBadge } from '@/components/commit-source-badge'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { LinkPreview } from '@/components/link-preview'; import { useNow } from '@/hooks/use-now'; @@ -46,7 +47,7 @@ export function QuotedCommit({ const content = ( <> - + {label} @@ -65,6 +66,7 @@ export function QuotedCommit({ > {formatRelativeTime(commit.created_at, now)} + - + ยท @@ -899,6 +900,7 @@ function CommitResult({ commit }: { commit: Commit }) { > {formatRelativeTime(commit.created_at, now)} + - +
+ + +
{scheduled.author && (

'019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', 'short_hash' => 'e3f4a5b', 'body' => 'Shipped the public API', + 'source' => null, 'parent_id' => null, 'html_url' => route('commit.show', [$user, $commit]), 'created_at' => now()->toIso8601ZuluString(), @@ -295,6 +297,7 @@ 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', 'short_hash' => 'e3f4a5b', 'body' => 'Ask @nunomaduro about `@Override`', + 'source' => null, 'parent_id' => $root->id, 'html_url' => route('commit.show', [$user, $commit]), 'created_at' => now()->toIso8601ZuluString(), @@ -324,6 +327,7 @@ 'id' => $root->id, 'short_hash' => mb_substr(str_replace('-', '', $root->id), -7), 'body' => 'Who broke prod?', + 'source' => null, 'parent_id' => null, 'html_url' => route('commit.show', [$root->user, $root]), 'created_at' => now()->subMinute()->toIso8601ZuluString(), @@ -577,6 +581,31 @@ expect($commit->status)->toBe(CommitStatus::Pending); }); +it('records a signed clients source from its established product name', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $body = json_encode(['body' => 'TUI post'], JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api-commit.store'), server: [ + ...signApiRequest($keyPair, 'POST', route('api-commit.store'), $body), + 'HTTP_USER_AGENT' => 'devnull-tui/1.2.3', + ], content: $body); + + $response->assertCreated()->assertJsonPath('data.source', 'tui'); + + expect($user->commits()->sole()->source)->toBe(CommitSource::Tui); +}); + +it('leaves a signed commit without a recognized client source unlabelled', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'Unknown client'])->assertCreated() + ->assertJsonPath('data.source', null); + + expect($user->commits()->sole()->source)->toBeNull(); +}); + it('publishes the commits of admins right away', function (): void { $user = User::factory()->create(); $keyPair = apiKeyPairFor($user); diff --git a/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php b/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php index 0ab8afb0a..f7228c0a0 100644 --- a/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php +++ b/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php @@ -4,6 +4,7 @@ use App\Actions\DeleteScheduledCommit; use App\Actions\UpdateScheduledCommitSubmittedAt; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\PollDuration; use App\Jobs\UnfurlLink; @@ -66,6 +67,26 @@ ->and(Commit::query()->count())->toBe(0); }); +it('carries the scheduled commits client source into publication', function (): void { + $body = json_encode([ + 'body' => 'Shipped from macOS.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ], JSON_THROW_ON_ERROR); + + $this->call('POST', route('api-scheduled-commit.store'), server: [ + ...signApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), $body), + 'HTTP_USER_AGENT' => 'DevNull-Mac/1.0', + ], content: $body)->assertCreated()->assertJsonPath('data.source', 'macos'); + + $scheduled = ScheduledCommit::query()->sole(); + expect($scheduled->source)->toBe(CommitSource::Macos); + + $this->travel(1)->hour(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + expect(Commit::query()->sole()->source)->toBe(CommitSource::Macos); +}); + it('schedules a poll that opens with its commit at the due time', function (): void { signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ 'body' => 'Tabs or spaces?', diff --git a/tests/Feature/Controllers/Cli/CommitControllerTest.php b/tests/Feature/Controllers/Cli/CommitControllerTest.php index 85b4d0baf..5ee31acbb 100644 --- a/tests/Feature/Controllers/Cli/CommitControllerTest.php +++ b/tests/Feature/Controllers/Cli/CommitControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Contracts\Services\GitHubProvider; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\ModerationReason; use App\Http\Middleware\AuthenticateSshSignature; @@ -82,7 +83,8 @@ function imageHashes(array $images): array ->assertCookieMissing(config('session.cookie')); expect($commit->body)->toBe('It was DNS.') - ->and($commit->status)->toBe(CommitStatus::Pending); + ->and($commit->status)->toBe(CommitStatus::Pending) + ->and($commit->source)->toBe(CommitSource::Cli); }); it('collapses runs of blank lines in a signed commit', function (): void { diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 8edc654c7..b35a4037b 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -4,6 +4,7 @@ use App\Actions\CreateQuoteNotifications; use App\Actions\DeleteUser; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Enums\Log; use App\Http\Middleware\HandleInertiaRequests; @@ -33,7 +34,8 @@ $response->assertRedirectToRoute('log.show', Log::Following); - expect($user->commits()->sole()->body)->toBe('Hello, devnull!'); + expect($user->commits()->sole()->body)->toBe('Hello, devnull!') + ->and($user->commits()->sole()->source)->toBe(CommitSource::Web); }); it('flashes the created commit', function (): void { diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 60160eaca..773cee732 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -7,6 +7,7 @@ use App\Actions\CreateExport; use App\Actions\DeleteUser; use App\Contracts\Services\TranslationProvider; +use App\Enums\CommitSource; use App\Enums\ModerationReason; use App\Enums\NotificationType; use App\Enums\OrganizationRole; @@ -215,7 +216,7 @@ $user = User::factory()->create(); $other = User::factory()->create(); $parent = Commit::factory()->for($other)->rejected()->create(['body' => 'Another developer private parent body']); - $approved = Commit::factory()->for($user)->create(['body' => "Shipped Unicode support ๐Ÿš€\nNext: docs.", 'pasteis_count' => 3]); + $approved = Commit::factory()->for($user)->create(['body' => "Shipped Unicode support ๐Ÿš€\nNext: docs.", 'source' => CommitSource::Tui, 'pasteis_count' => 3]); $approved->headlines()->create(['name' => 'Unicode support ships']); $pending = Commit::factory()->for($user)->pending()->create(); $rejected = Commit::factory()->for($user)->rejected()->create(); @@ -243,6 +244,7 @@ 'parent_id' => null, 'quoted_commit_id' => null, 'body' => $approved->body, + 'source' => 'tui', 'cached_translations' => [ 'source_language' => 'en', 'translations' => ['nl' => 'Unicode-ondersteuning geleverd ๐Ÿš€'], @@ -264,6 +266,19 @@ ->and($response->streamedContent())->not->toContain($parent->body, $otherReply->body); }); +it('exports the source of commits posted for organizations', function (): void { + $user = User::factory()->create(); + $organization = Organization::factory()->create(['handle' => 'laravel']); + Commit::factory()->for($user)->create(['author_id' => $organization->id, 'source' => CommitSource::Web]); + Commit::factory()->for($user)->create(['author_id' => $organization->id, 'source' => null]); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect(collect($data['organization_commits'])->pluck('source')->sort()->values()->all()) + ->toBe([null, 'web']); +}); + it('exports commit image urls in order', function (): void { $user = User::factory()->create(); $commit = Commit::factory()->for($user)->create(); diff --git a/tests/Unit/CommitSourceBadge.test.tsx b/tests/Unit/CommitSourceBadge.test.tsx new file mode 100644 index 000000000..aaeeafc0a --- /dev/null +++ b/tests/Unit/CommitSourceBadge.test.tsx @@ -0,0 +1,75 @@ +/// + +import assert from 'node:assert/strict'; +import { Children, isValidElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import { CommitSourceBadge } from '../../resources/js/components/commit-source-badge'; +import { TooltipProvider } from '../../resources/js/components/ui/tooltip'; + +const labels = { + web: 'web', + cli: 'cli', + tui: 'tui', + ios: 'ios', + android: 'android', + macos: 'macos', + windows: 'windows', + linux: 'linux', +}; + +for (const [source, label] of Object.entries(labels)) { + test(`labels ${source} commits as ${label}`, () => { + const badge = renderToStaticMarkup( + + + , + ); + + assert.match( + badge, + new RegExp( + `Committed via ${label}$`, + ), + ); + assert.doesNotMatch(badge, /aria-label=/); + }); +} + +test('shows the source in its lazy hover tooltip', () => { + const badge = CommitSourceBadge({ source: 'web' }); + const tooltip = Children.toArray(badge?.props.children).at(1); + + assert.ok(isValidElement<{ children: ReactNode }>(tooltip)); + assert.equal( + renderToStaticMarkup(<>{tooltip.props.children}), + 'Committed via web', + ); +}); + +test('renders a newly introduced backend source without a frontend change', () => { + const badge = renderToStaticMarkup( + + + , + ); + + assert.match( + badge, + /Committed via <\/span>headless<\/span>$/, + ); + assert.doesNotMatch(badge, /aria-label=/); +}); + +for (const source of [null, undefined]) { + test(`omits a ${String(source)} source`, () => { + assert.equal( + renderToStaticMarkup( + + + , + ), + '', + ); + }); +} diff --git a/tests/Unit/QuotedCommit.test.tsx b/tests/Unit/QuotedCommit.test.tsx index a790caf25..b18634327 100644 --- a/tests/Unit/QuotedCommit.test.tsx +++ b/tests/Unit/QuotedCommit.test.tsx @@ -25,6 +25,7 @@ const commit = { ], preview_segments: null, status: 'approved', + source: 'tui', created_at: '2026-10-16T11:51:00.000000Z', author: { id: 'author', name: 'Matthieu Napoli', handle: 'mnapoli' }, } as QuotedCommitData; @@ -85,6 +86,17 @@ test('keeps the quote a single link', async () => { assert.doesNotMatch(body, / { + const source = await render(false); + const unknown = await render(false, { ...commit, source: null }); + + assert.match( + source, + /data-test="commit-source"[^>]*>Committed via <\/span>tui { const image = { id: 'image', diff --git a/tests/Unit/Services/CommitSourceResolverTest.php b/tests/Unit/Services/CommitSourceResolverTest.php new file mode 100644 index 000000000..216cd7932 --- /dev/null +++ b/tests/Unit/Services/CommitSourceResolverTest.php @@ -0,0 +1,33 @@ + $expected->value]); + + expect(resolve(CommitSourceResolver::class)->from($request))->toBe($expected); +})->with(CommitSource::cases()); + +it('recognizes established client product names', function (string $agent, CommitSource $expected): void { + $request = Request::create('/', server: ['HTTP_USER_AGENT' => $agent]); + + expect(resolve(CommitSourceResolver::class)->from($request))->toBe($expected); +})->with([ + ['DevNull-Mac/1.0', CommitSource::Macos], + ['devnull-tui/1.2.3', CommitSource::Tui], +]); + +it('leaves unknown client identifiers unlabelled', function (array $server): void { + $request = Request::create('/', server: $server); + + expect(resolve(CommitSourceResolver::class)->from($request))->toBeNull(); +})->with([ + 'no identifier' => [[]], + 'unknown header' => [['HTTP_X_DEVNULL_CLIENT' => 'browser']], + 'generic browser' => [['HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)']], + 'near mac product' => [['HTTP_USER_AGENT' => 'DevNull-Mac']], +]);