Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions app/Enums/AgentContextFileKind.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,7 @@ public static function fromPath(string $relPath): ?self
// `.mdc` is Cursor's rule-file extension.
$hasRuleExtension = str_ends_with($relPath, '.md') || str_ends_with($relPath, '.mdc');

// Bail on ordinary source files before the rule scan: every rule needs
// a rule extension or a dot-prefixed segment. Hot path — fromPath()
// runs for every entry of the working-tree walk.
// Every rule needs a rule extension or a dot-prefixed segment.
if (! $hasRuleExtension && ! str_contains($haystack, '/.')) {
return null;
}
Expand Down
167 changes: 40 additions & 127 deletions app/Services/AgentContextFileScannerService.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,9 @@
use App\Enums\AgentContextFileKind;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Process;

class AgentContextFileScannerService
{
/**
* Cap recursion depth when walking for untracked candidates, mirroring
* ExternalFilesService::MAX_DEPTH. Without it a pathologically deep tree
* could exhaust the PHP recursion stack on the synchronous Context-page scan.
*/
private const MAX_SCAN_DEPTH = 8;

public function __construct(
private readonly GitProcessService $git,
private readonly GitDiffService $gitDiffService,
Expand All @@ -28,10 +20,9 @@ public function __construct(
* Discover every agent context file inside $repoPath, ordered by path.
* See AgentContextFileKind for the conventions recognised.
*
* Tracked entries come from `git ls-files`; untracked candidates are walked
* from disk and filtered through `git check-ignore` in a single batch. Skip
* dirs come from config('rfa.context_scan_skip_dirs') and may be augmented
* via $extraSkipDirs (used by tests).
* Tracked and untracked entries come from `git ls-files`. Untracked entries
* use Git's standard excludes, and configured skip directories are excluded
* from discovery. Additional skip directories may be supplied per scan.
*
* @param array<int, string> $extraSkipDirs
* @return array<int, AgentContextFile>
Expand All @@ -44,7 +35,7 @@ public function scan(string $repoPath, array $extraSkipDirs = []): array
)));

$tracked = $this->discoverTracked($repoPath, $skipDirs);
$untracked = $this->discoverUntracked($repoPath, $skipDirs, array_keys($tracked));
$untracked = $this->discoverUntracked($repoPath, $skipDirs);

// Symlink dedupe is keyed by `realpath()` of the absolute path. We
// resolve both symlinked AND non-symlink entries so the keys agree on
Expand All @@ -55,7 +46,7 @@ public function scan(string $repoPath, array $extraSkipDirs = []): array
// entry: prefer the non-symlink, then the shorter path. That way the
// tree shows the actual file rather than its mirror.
$byRealpath = [];
foreach ([...array_values($tracked), ...$untracked] as $file) {
foreach ([...$tracked, ...$untracked] as $file) {
$key = realpath($file->absolutePath) ?: $file->absolutePath;
$existing = $byRealpath[$key] ?? null;

Expand All @@ -72,7 +63,7 @@ public function scan(string $repoPath, array $extraSkipDirs = []): array

/**
* @param array<int, string> $skipDirs
* @return array<string, AgentContextFile> keyed by repo-relative path
* @return array<int, AgentContextFile>
*/
private function discoverTracked(string $repoPath, array $skipDirs): array
{
Expand Down Expand Up @@ -108,7 +99,7 @@ private function discoverTracked(string $repoPath, array $skipDirs): array
$symlinkTarget = $isSymlink ? readlink($absolute) : null;
[$createdAt, $lastEditedAt] = $datesByPath[$relPath] ?? [null, null];

$entries[$relPath] = new AgentContextFile(
$entries[] = new AgentContextFile(
path: $relPath,
absolutePath: $absolute,
kind: $kind,
Expand All @@ -125,32 +116,37 @@ private function discoverTracked(string $repoPath, array $skipDirs): array
}

/**
* Walk the filesystem for agent-context files outside the tracked set,
* batch-filter via `git check-ignore --stdin`, return whatever survives.
* Discover untracked agent-context files while letting Git exclude ignored
* directories before it traverses them.
*
* @param array<int, string> $skipDirs
* @param array<int, string> $trackedPaths
* @return array<int, AgentContextFile>
*/
private function discoverUntracked(string $repoPath, array $skipDirs, array $trackedPaths): array
private function discoverUntracked(string $repoPath, array $skipDirs): array
{
$candidates = [];
$trackedSet = array_flip($trackedPaths);

$this->walkForCandidates($repoPath, '', $skipDirs, $trackedSet, $candidates);
$output = rescue(
fn (): string => $this->git->run($repoPath, [
'ls-files', '--others', '--exclude-standard', '-z',
Comment thread
fgilio marked this conversation as resolved.
'--', ...AgentContextFileKind::gitPathspecs(),
...$this->gitExclusionPathspecs($skipDirs),
]),
rescue: null,
report: false,
);

if ($candidates === []) {
if ($output === null) {
return [];
}

$ignored = $this->batchCheckIgnore($repoPath, array_keys($candidates));
/** @var array<string, AgentContextFileKind> $kindsByPath */
$kindsByPath = collect($this->splitNullDelimited($output))
->reject(fn (string $path): bool => $this->isSkipped($path, $skipDirs))
->mapWithKeys(fn (string $path): array => [$path => AgentContextFileKind::fromPath($path)])
->whereNotNull()
->all();

$entries = [];
foreach ($candidates as $relPath => $kind) {
if (isset($ignored[$relPath])) {
continue;
}

foreach ($kindsByPath as $relPath => $kind) {
$absolute = $repoPath.'/'.$relPath;
$isSymlink = is_link($absolute);
$symlinkTarget = $isSymlink ? readlink($absolute) : null;
Expand All @@ -172,102 +168,6 @@ private function discoverUntracked(string $repoPath, array $skipDirs, array $tra
return $entries;
}

/**
* @param array<int, string> $skipDirs
* @param array<string, int> $trackedSet Flipped paths for O(1) skip lookup.
* @param array<string, AgentContextFileKind> $candidates Mutated in place.
*/
private function walkForCandidates(string $repoPath, string $relDir, array $skipDirs, array $trackedSet, array &$candidates, int $depth = 0): void
{
if ($depth > self::MAX_SCAN_DEPTH) {
return;
}

$absoluteDir = $relDir === '' ? $repoPath : $repoPath.'/'.$relDir;

if (! File::isDirectory($absoluteDir)) {
return;
}

// is_link before is_dir avoids descending into symlinked dirs (which
// can loop or escape the repo). We still match symlinked files below.
if ($relDir !== '' && is_link($absoluteDir)) {
return;
}

$handle = @opendir($absoluteDir);
if ($handle === false) {
return;
}

try {
while (($entry = readdir($handle)) !== false) {
if ($entry === '.' || $entry === '..') {
continue;
}

$relPath = $relDir === '' ? $entry : $relDir.'/'.$entry;

if ($this->isSkipped($relPath, $skipDirs)) {
continue;
}

$absolutePath = $absoluteDir.'/'.$entry;

if (is_dir($absolutePath) && ! is_link($absolutePath)) {
$this->walkForCandidates($repoPath, $relPath, $skipDirs, $trackedSet, $candidates, $depth + 1);

continue;
}

if (isset($trackedSet[$relPath])) {
continue;
}

$kind = AgentContextFileKind::fromPath($relPath);
if ($kind === null) {
continue;
}

$candidates[$relPath] = $kind;
}
} finally {
closedir($handle);
}
}

/**
* One shell-out for the whole list. Returns a set of repo-relative paths
* that ARE ignored (so callers can skip them).
*
* @param array<int, string> $candidates
* @return array<string, true>
*/
private function batchCheckIgnore(string $repoPath, array $candidates): array
{
$process = new Process([
'git', '-c', 'core.quotepath=false', '-C', $repoPath,
'check-ignore', '--stdin', '-z',
]);
$process->setTimeout(30);
$process->setInput(implode("\0", $candidates));
$process->run();

// git check-ignore exits 0 when at least one path is ignored, 1 when
// none are, 128 on hard failure. Treat 0 and 1 as success.
$exit = $process->getExitCode();
if ($exit !== 0 && $exit !== 1) {
return [];
}

$ignored = [];
foreach ($this->splitNullDelimited($process->getOutput()) as $path) {
$ignored[$path] = true;
}

return $ignored;
}

/**
* Resolve created/last-edited dates for every tracked path in a single
* git log call. Drops `--follow` (which only accepts one pathspec) in
Expand Down Expand Up @@ -381,6 +281,19 @@ private function isSkipped(string $relPath, array $skipDirs): bool
return false;
}

/**
* @param array<int, string> $skipDirs
* @return array<int, string>
*/
private function gitExclusionPathspecs(array $skipDirs): array
{
return collect($skipDirs)
->filter(fn (string $directory): bool => $directory !== '')
->map(fn (string $directory): string => ':(top,exclude,literal)'.$directory)
->values()
->all();
}

/** @return array<int, string> */
private function splitNullDelimited(string $output): array
{
Expand Down
7 changes: 1 addition & 6 deletions config/rfa.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,7 @@
'animation_class_summary_limit' => env('RFA_DIAGNOSTICS_ANIMATION_CLASS_SUMMARY_LIMIT', 20),
],

/*
| Path prefixes (from repo root) the AgentContextFileScanner skips when
| walking the working tree for untracked agent context file candidates.
| The defaults mirror the build artifact locations the Context page design
| doc calls out (the 5 stale copies under nativephp/electron/dist).
*/
/* Repo-root path prefixes excluded from agent context discovery. */
'context_scan_skip_dirs' => [
'nativephp/electron/dist',
'vendor',
Expand Down
21 changes: 20 additions & 1 deletion resources/views/components/feedback-submit-bar.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,26 @@
pages.
--}}

<div class="fixed bottom-0 left-0 right-0 z-50 bg-gh-bg/80 backdrop-blur-sm border-t border-gh-border">
<div
data-feedback-submit-bar
data-testid="feedback-submit-bar"
class="fixed bottom-0 left-0 right-0 z-50 bg-gh-bg/80 backdrop-blur-sm border-t border-gh-border"
x-data="{
feedbackBarObserver: null,
updateFeedbackBarHeight() {
document.documentElement.style.setProperty('--feedback-bar-h', $el.offsetHeight + 'px');
},
init() {
this.updateFeedbackBarHeight();
this.feedbackBarObserver = new ResizeObserver(() => this.updateFeedbackBarHeight());
this.feedbackBarObserver.observe($el);
},
destroy() {
this.feedbackBarObserver?.disconnect();
},
}"
@input="$nextTick(() => updateFeedbackBarHeight())"
>
@if($submitted)
<div class="px-5 py-3.5 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
Expand Down
12 changes: 6 additions & 6 deletions resources/views/components/resizable-sidebar-shell.blade.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
@props(['mainClass' => ''])

{{-- Width persists across pages via Alpine.store('settings').sidebarWidth. --}}

<div
{{ $attributes->merge(['class' => 'flex']) }}
:style="{ '--sidebar-w': $store.settings.sidebarWidth + 'px' }"
x-data="{
resizing: false,
startResize($event) {
Expand Down Expand Up @@ -37,6 +36,7 @@
aside.style.left = '';
aside.style.zIndex = '';
aside.style.willChange = '';
aside.style.width = 'var(--sidebar-w, 288px)';
main.style.marginLeft = '';
this.resizing = false;
document.body.classList.remove('cursor-col-resize', 'select-none');
Expand All @@ -58,7 +58,7 @@
>
<aside
class="shrink-0 sticky top-[var(--header-h)] h-[calc(100vh-var(--header-h))] overflow-y-auto border-r border-gh-border bg-gh-bg hidden lg:block"
:style="{ width: $store.settings.sidebarWidth + 'px' }"
style="width: var(--sidebar-w, 288px); height: calc(100vh - var(--header-h) - var(--feedback-bar-h));"
x-ref="sidebar"
>
{{ $sidebar }}
Expand All @@ -71,7 +71,7 @@ class="shrink-0 sticky top-[var(--header-h)] h-[calc(100vh-var(--header-h))] ove
aria-label="Resize sidebar"
title="Drag to resize · double-click to reset"
class="group/resize hidden lg:flex sticky top-[var(--header-h)] h-[calc(100vh-var(--header-h))] w-0 cursor-col-resize items-center justify-center z-10 shrink-0"
style="padding: 0 6px; margin: 0 -6px;"
style="height: calc(100vh - var(--header-h) - var(--feedback-bar-h)); padding: 0 6px; margin: 0 -6px;"
@mousedown="startResize($event)"
@dblclick="$store.settings.sidebarWidth = 288"
>
Expand All @@ -84,9 +84,9 @@ class="group/resize hidden lg:flex sticky top-[var(--header-h)] h-[calc(100vh-va
</div>

<main
class="flex-1 min-w-0 {{ $mainClass }}"
class="flex-1 min-w-0"
:class="resizing && 'pointer-events-none'"
style="contain: inline-size layout style"
style="contain: inline-size layout style; padding-bottom: var(--feedback-bar-h)"
>
{{ $slot }}
</main>
Expand Down
1 change: 1 addition & 0 deletions resources/views/layouts/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@

:root {
--header-h: 56px;
--feedback-bar-h: 128px;
@foreach($lightColors as $key => $value)
--gh-{{ $key }}: {{ $value }};
@endforeach
Expand Down
2 changes: 1 addition & 1 deletion resources/views/pages/⚡context-page.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ class="min-h-screen flex flex-col"
</div>
</x-page-header>

<x-resizable-sidebar-shell class="flex-1" main-class="pb-32">
<x-resizable-sidebar-shell class="flex-1">
<x-slot:sidebar>
<div class="p-4">
<livewire:context-tree
Expand Down
2 changes: 1 addition & 1 deletion resources/views/pages/⚡review-page.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -1536,7 +1536,7 @@ class="absolute -top-0.5 -right-0.5"
<x-commit-context-bar :commit-info="$commitInfo" :project-slug="$projectSlug" />
@endif

<x-resizable-sidebar-shell main-class="pb-24">
<x-resizable-sidebar-shell>
<x-slot:sidebar>
<div class="p-4">
@if(! $this->isCommitMode() && count($reviewPairs) > 0)
Expand Down
Loading