chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Search finds code, with a filter down the side
.knotless/workflows/test.yml+1−1
| @@ -64,7 +64,7 @@ steps: | ||
[ "$(cat /opt/tools/.zoekt 2>/dev/null)" = "$ZOEKT_COMMIT" ] && exit 0 | ||
mkdir -p /opt/tools | ||
go=$(mktemp -d) | ||
curl -fsSL "https://go.dev/dl/go$GO_VERSION.linux-$(dpkg --print-architecture).tar.gz" | tar -xz -C "$go" | ||
curl -fsSL --retry 5 --retry-all-errors "https://go.dev/dl/go$GO_VERSION.linux-$(dpkg --print-architecture).tar.gz" | tar -xz -C "$go" | ||
CGO_ENABLED=0 GOBIN=/opt/tools GOPATH="$go/path" GOCACHE="$go/cache" GOTOOLCHAIN=local \ | ||
"$go/go/bin/go" install \ | ||
"github.com/sourcegraph/zoekt/cmd/zoekt-git-index@$ZOEKT_COMMIT" \ | ||
crates/knotless-serve/src/web/search.rs+3−0
| @@ -22,6 +22,8 @@ struct FileView { | ||
path: String, | ||
#[serde(skip_serializing_if = "String::is_empty")] | ||
language: String, | ||
#[serde(skip_serializing_if = "String::is_empty")] | ||
commit: String, | ||
chunks: Vec<crate::code::Chunk>, | ||
} | ||
| ||
| @@ -63,6 +65,7 @@ pub async fn search_code( | ||
repo, | ||
path: hit.path, | ||
language: hit.language, | ||
commit: hit.commit, | ||
chunks: hit.chunks, | ||
} | ||
}) | ||
crates/knotless-serve/src/code.rs+6−0
| @@ -49,6 +49,8 @@ pub struct Hit { | ||
pub key: String, | ||
pub path: String, | ||
pub language: String, | ||
/// The commit of its default branch indexed, which its lines are of. | ||
pub commit: String, | ||
pub chunks: Vec<Chunk>, | ||
} | ||
| ||
| @@ -105,6 +107,8 @@ struct ZFile { | ||
repository: String, | ||
language: String, | ||
#[serde(default)] | ||
version: String, | ||
#[serde(default)] | ||
chunk_matches: Option<Vec<ZChunk>>, | ||
} | ||
| ||
| @@ -284,6 +288,7 @@ fn hit(file: ZFile) -> Option<Hit> { | ||
key: key.to_string(), | ||
path: file.file_name, | ||
language: file.language, | ||
commit: file.version, | ||
chunks, | ||
}) | ||
} | ||
| @@ -440,6 +445,7 @@ mod tests { | ||
file_name: "a".into(), | ||
repository: "no-slash".into(), | ||
language: String::new(), | ||
version: String::new(), | ||
chunk_matches: None, | ||
}) | ||
.is_none() | ||
crates/knotless-serve/tests/web.rs+2−0
| @@ -3491,6 +3491,8 @@ fn searching_code() { | ||
assert_eq!(file["repo"]["owner"]["handle"], handle.as_str()); | ||
assert_eq!(file["path"], "src/lib.rs"); | ||
assert_eq!(file["language"], "Rust"); | ||
let commit = s.git(&s.path("needle-one"), &["rev-parse", "HEAD"]); | ||
assert_eq!(file["commit"], commit.trim()); | ||
let chunk = &file["chunks"][0]; | ||
assert_eq!(chunk["startLine"], 1, "{chunk}"); | ||
assert_eq!( | ||
docs/guide/server.md+4−0
| @@ -101,6 +101,10 @@ they may read come first, marked so. On a repository's pages, ⌘K opens | ||
the palette on that repository instead: its pages to jump to, and its | ||
open pulls and issues whose words match what is typed. Backspace with | ||
nothing typed widens it to all of knotless, which `/` always searches. | ||
On a server run with `--code-index`, the palette also searches code for | ||
what is typed, in every public repository or in the one it is open on. | ||
`/search?q=…&type=code` lists the files found, with the lines around each | ||
match, and `repo:owner/name` in the query searches one repository. | ||
The front page, repositories, pulls and issues unfurl into social cards | ||
that the server draws itself. | ||
| ||
docs/reference/api.md+2−1
| @@ -164,7 +164,8 @@ alone. | ||
| `limit` | 20 | the most files answered, clamped to 1 to 50 | | ||
| ||
Answers `files`, the best first, each with its `repo` (as `listRepos` | ||
answers one), `path`, `language?` and `chunks`: runs of lines holding | ||
answers one), `path`, `language?`, `commit?` (the commit of the default | ||
branch that was indexed, which its lines are of) and `chunks`: runs of lines holding | ||
matches, each its `startLine`, its `content` and its `ranges`, a match | ||
by `line`, `byteStart` and `byteEnd` in UTF-8 bytes. `fileCount` and | ||
`matchCount` count all that was found. Only default branches are | ||
lexicons/dev/knotless/searchCode.json+1−0
| @@ -36,6 +36,7 @@ | ||
"repo": { "type": "ref", "ref": "dev.knotless.listRepos#repoView" }, | ||
"path": { "type": "string", "maxLength": 4096, "description": "The file's path in the repository's default branch." }, | ||
"language": { "type": "string", "maxLength": 100, "description": "What it is written in, as Zoekt names it from its extension or contents." }, | ||
"commit": { "type": "string", "maxLength": 64, "description": "The commit of the default branch that was indexed, whose lines these are: a page of the file links there, as the branch may have moved since." }, | ||
"chunks": { "type": "array", "items": { "type": "ref", "ref": "#chunk" }, "description": "Runs of lines holding matches, in order. Empty when only the path matched." } | ||
} | ||
}, | ||
web/src/lib/organisms/CodeResults.sveltenew+58−0
| @@ -0,0 +1,58 @@ | ||
<script lang="ts"> | ||
import type { Chunk, FileView, Range } from '#lib/lexicons/dev/knotless/searchCode.ts'; | ||
import { repoHref, urlPath } from '#lib/format.ts'; | ||
import Avatar from '#lib/atoms/Avatar.svelte'; | ||
/** | ||
* Files a code search found, each in a box: whose repository and its | ||
* path over the lines around what was found, each line's number linking | ||
* to that line of the file, at the commit that was searched, and what | ||
* was found marked. | ||
*/ | ||
let { files }: { files: FileView[] } = $props(); | ||
| ||
const encoder = new TextEncoder(); | ||
const decoder = new TextDecoder(); | ||
| ||
/** `line` in pieces, those `ranges` found marked: they count UTF-8 bytes. */ | ||
function pieces(line: string, ranges: Range[]): { text: string; found: boolean }[] { | ||
const bytes = encoder.encode(line); | ||
const out: { text: string; found: boolean }[] = []; | ||
let at = 0; | ||
for (const r of [...ranges].sort((a, b) => a.byteStart - b.byteStart)) { | ||
const start = Math.max(at, r.byteStart); | ||
if (start > at) out.push({ text: decoder.decode(bytes.slice(at, start)), found: false }); | ||
if (r.byteEnd > start) out.push({ text: decoder.decode(bytes.slice(start, r.byteEnd)), found: true }); | ||
at = Math.max(at, r.byteEnd); | ||
} | ||
if (at < bytes.length) out.push({ text: decoder.decode(bytes.slice(at)), found: false }); | ||
return out; | ||
} | ||
| ||
/** A chunk's lines, by number, in pieces. */ | ||
const lines = (c: Chunk) => | ||
c.content.split('\n').map((text, i) => { | ||
const n = c.startLine + i; | ||
return { n, pieces: pieces(text, c.ranges.filter((r) => r.line === n)) }; | ||
}); | ||
| ||
/** The file `f`, at the commit searched, at line `n` if given. */ | ||
const href = (f: FileView, n?: number) => repoHref(f.repo.owner?.handle ?? f.repo.did, f.repo.name, `/blob/${urlPath(f.commit ?? 'HEAD')}/${urlPath(f.path)}${n ? `#L${n}` : ''}`); | ||
</script> | ||
| ||
<div class="code-results"> | ||
{#each files as f (f.repo.did + '/' + f.repo.name + '/' + f.path)} | ||
{@const owner = f.repo.owner?.handle ?? f.repo.did} | ||
<section class="box"> | ||
<header class="boxhead"> | ||
<Avatar name={owner} did={f.repo.did} src={f.repo.owner?.avatar} /> | ||
<span class="code-result-name"><a class="mute" href={repoHref(owner, f.repo.name)}>{owner}/{f.repo.name}</a> <span class="faint">·</span> <a href={href(f)}><b>{#each f.path.split('/') as part, k (k)}{#if k}/<wbr />{/if}{part}{/each}</b></a></span> | ||
{#if f.language}<span class="code-result-lang faint">{f.language}</span>{/if} | ||
</header> | ||
{#if !f.chunks.length}<p class="code-result-path mute">Its path matches.</p>{/if} | ||
{#each f.chunks as c, i (i)} | ||
{@const shown = lines(c)} | ||
<div class="code code-result"><pre class="ln" aria-hidden="true">{#each shown as l (l.n)}<a href={href(f, l.n)}>{l.n}</a>{'\n'}{/each}</pre><pre class="src"><code>{#each shown as l (l.n)}{#each l.pieces as p, j (j)}{#if p.found}<mark>{p.text}</mark>{:else}{p.text}{/if}{/each}{'\n'}{/each}</code></pre></div> | ||
{/each} | ||
</section> | ||
{/each} | ||
</div> | ||
web/src/lib/organisms/SearchPalette.svelte+16−1
| @@ -20,7 +20,8 @@ | ||
* On a repository's pages, ⌘K opens it on that repository instead: its | ||
* pages to jump to, and its open pulls and issues as they are typed. | ||
* Backspace with nothing typed leaves the repository for all of | ||
* knotless, as `/` always opens. | ||
* knotless, as `/` always opens. What is typed can be searched for in | ||
* code too: everywhere's, or on a repository, its own. | ||
*/ | ||
type Repo = Answer<typeof listRepos.main>['repos'][number]; | ||
type Shown = Repo & { owner: NonNullable<Repo['owner']> }; | ||
| @@ -68,11 +69,19 @@ | ||
const all = $derived(value.trim() ? `/search?q=${encodeURIComponent(value.trim())}` : '/search'); | ||
// On a repository with nothing typed, its pages are all there is: all results are of knotless. | ||
const everywhere = $derived(!scope || !!value.trim()); | ||
// What was typed, searched for in code: the repository's own, on one. | ||
const code = $derived.by(() => { | ||
const q = value.trim(); | ||
if (!q) return null; | ||
const within = scope ? `repo:${scope.authority}/${scope.name} ${q}` : q; | ||
return `/search?${new URLSearchParams({ q: within, type: 'code' })}`; | ||
}); | ||
const hrefs = $derived([ | ||
...pages.map((p) => p.href), | ||
...(scope ? items.map((i) => itemHref(repoHref(scope!.authority, scope!.name), i.uri)) : []), | ||
...repos.map((r) => repoHref(r.owner.handle, r.name)), | ||
...owners.map((o) => `/${urlPath(o.owner.handle)}`), | ||
...(code ? [code] : []), | ||
...(everywhere ? [all] : []) | ||
]); | ||
| ||
| @@ -194,6 +203,12 @@ | ||
<a href="/{urlPath(o.owner.handle)}" onclick={() => dialog.close()} onmousemove={() => (at = i)}><Avatar name={o.owner.handle} did={o.owner.did} src={o.owner.avatar} /><b class="name clip">{o.owner.handle}</b><span class="clip faint">{plural(o.repoCount, 'repository', 'repositories')}</span></a> | ||
</li> | ||
{/each} | ||
{#if code} | ||
{@const i = hrefs.length - (everywhere ? 2 : 1)} | ||
<li id="palette-{i}" class="all" role="option" aria-selected={at === i}> | ||
<a href={code} onclick={() => dialog.close()} onmousemove={() => (at = i)}>{scope ? `“${value.trim()}” in this repository’s code →` : `code containing “${value.trim()}” →`}</a> | ||
</li> | ||
{/if} | ||
{#if everywhere} | ||
<li id="palette-{hrefs.length - 1}" class="all" role="option" aria-selected={at === hrefs.length - 1}> | ||
<a href={all} onclick={() => dialog.close()} onmousemove={() => (at = hrefs.length - 1)}>{scope ? `“${value.trim()}” in all of knotless →` : value.trim() ? `all results for “${value.trim()}” →` : 'all repositories →'}</a> | ||
web/src/routes/search/+page.svelte+63−11
| @@ -1,22 +1,74 @@ | ||
<script lang="ts"> | ||
import RepoList from '#lib/organisms/RepoList.svelte'; | ||
import OwnerCards from '#lib/organisms/OwnerCards.svelte'; | ||
import CodeResults from '#lib/organisms/CodeResults.svelte'; | ||
import Meta from '#lib/atoms/Meta.svelte'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
import Empty from '#lib/molecules/Empty.svelte'; | ||
import { plural, repoHref } from '#lib/format.ts'; | ||
let { data } = $props(); | ||
const q = $derived(data.query); | ||
const topic = $derived(data.topic); | ||
const heading = $derived(topic ? `Repositories about ${topic}` : q ? `Results for “${q}”` : 'Repositories'); | ||
const code = $derived(data.code); | ||
const heading = $derived(topic ? `Repositories about ${topic}` : q ? `Results for “${q}”` : data.type === 'code' ? 'Code' : 'Repositories'); | ||
const title = $derived(topic ? `${topic} · knotless` : q ? `“${q}” · knotless` : data.type === 'code' ? 'code · knotless' : 'repositories · knotless'); | ||
| ||
/** This search, as `type`. */ | ||
const of = (type: 'code' | 'repositories', query = q) => { | ||
const params = new URLSearchParams(); | ||
if (query) params.set('q', query); | ||
if (topic && type === 'repositories') params.set('topic', topic); | ||
if (type === 'code') params.set('type', 'code'); | ||
return `/search${params.size ? `?${params}` : ''}`; | ||
}; | ||
| ||
// The repository `repo:` keeps the search to, and the search without it. | ||
const within = $derived(/(?:^|\s)repo:(\S+?)\/(\S+)(?=\s|$)/.exec(q)); | ||
const everywhere = $derived(of('code', q.replace(/(^|\s)repo:\S+/, ' ').trim())); | ||
| ||
// The languages of the files found, most first, each to narrow the search to. | ||
const languages = $derived.by(() => { | ||
if (code.kind !== 'found' || /(^|\s)lang:/.test(q)) return []; | ||
const counted = new Map<string, number>(); | ||
for (const f of code.answer.files) if (f.language) counted.set(f.language, (counted.get(f.language) ?? 0) + 1); | ||
return [...counted].sort((a, b) => b[1] - a[1]).map(([name]) => name); | ||
}); | ||
</script> | ||
| ||
<Meta title={topic ? `${topic} · knotless` : q ? `“${q}” · knotless` : 'repositories · knotless'} /> | ||
<Meta {title} /> | ||
| ||
<section class="results"> | ||
<h1>{heading}</h1> | ||
{#if data.owners.length} | ||
<h2>Owners</h2> | ||
<OwnerCards owners={data.owners} /> | ||
<h2>Repositories</h2> | ||
{/if} | ||
<RepoList repos={data.repos}>{#snippet empty()}{#if data.indexed}<Empty title="No repositories found" />{:else}<Empty title="Search is off here">This server keeps no index to search.</Empty>{/if}{/snippet}</RepoList> | ||
</section> | ||
<div class="search-page results"> | ||
<nav class="account-nav search-nav" aria-label="Filter by"> | ||
<p class="search-nav-head">Filter by</p> | ||
<a href={of('code')} aria-current={data.type === 'code' ? 'page' : undefined}><Icon name="code" />Code</a> | ||
<a href={of('repositories')} aria-current={data.type === 'repositories' ? 'page' : undefined}><Icon name="repo" />Repositories</a> | ||
{#if languages.length} | ||
<p class="search-nav-head search-langs">Languages</p> | ||
{#each languages as l (l)}<a class="search-langs" href={of('code', `${q} lang:${l.includes(' ') ? `"${l}"` : l}`)}>{l}</a>{/each} | ||
{/if} | ||
</nav> | ||
<div class="settings-main"> | ||
<h1>{heading}</h1> | ||
{#if data.type === 'code'} | ||
{#if code.kind === 'found' && code.answer.files.length} | ||
<p class="search-count mute">{plural(code.answer.fileCount, 'file', 'files')}{#if within}{' in '}<a href={repoHref(within[1], within[2])}>{within[1]}/{within[2]}</a>{' · '}<a href={everywhere}>Search all of knotless</a>{/if}</p> | ||
<CodeResults files={code.answer.files} /> | ||
{:else} | ||
<div class="box"> | ||
{#if code.kind === 'off'}<Empty title="Code search is off here">This server searches no code.</Empty> | ||
{:else if code.kind === 'missing'}<Empty title="No repository {code.repo}">Check its owner and name, or <a href={everywhere}>search all of knotless</a>.</Empty> | ||
{:else if code.kind === 'bad'}<Empty title="That search can’t be read">{code.message}</Empty> | ||
{:else if code.kind === 'found'}<Empty title="No code found">{#if within}Nothing in {within[1]}/{within[2]} matches. <a href={everywhere}>Search all of knotless</a> instead.{:else}Only each repository’s default branch is searched. <a href={of('repositories')}>Search repositories</a> instead.{/if}</Empty> | ||
{:else}<Empty title="Search code">Type what to look for, as a word, "quoted text" or /a regex/. Add repo:owner/name to search one repository.</Empty>{/if} | ||
</div> | ||
{/if} | ||
{:else} | ||
{#if data.owners.length} | ||
<h2>Owners</h2> | ||
<OwnerCards owners={data.owners} /> | ||
<h2>Repositories</h2> | ||
{/if} | ||
<RepoList repos={data.repos}>{#snippet empty()}{#if data.indexed}<Empty title="No repositories found" />{:else}<Empty title="Search is off here">This server keeps no index to search.</Empty>{/if}{/snippet}</RepoList> | ||
{/if} | ||
</div> | ||
</div> | ||