~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Search finds code, with a filter down the side

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/code-search
.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>
web/src/routes/search/+page.ts+42−4
web/src/app.css+16−3