vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Search every issue, change and comment from the top bar
6c03e6d3243d31a82210cc7bc689ab3e28226b6c · Keenan Nemetz · 9d ago · parent 389317ee
Search every issue, change and comment from the top bar The top bar's search now opens a command palette instead of a text field. The dialog asks the same index and visibility rule as /search, so private repositories stay out, and it answers with a short list plus a link to the full results. A button that looks like the old input launches it, labelled with the platform's shortcut; Ctrl K or Cmd K works anywhere. The bar's contents also line up with the page column now, and Home is marked current only on the landing page rather than on every repository page.
server/web/static/vole.css +176−11
| @@ -8,7 +8,8 @@ | |||
| 8 | 8 | what it was quietly overriding: | |
| 9 | 9 | ||
| 10 | 10 | tokens custom properties, dark and light (:root) | |
| 11 | chrome document shell, global bar, account menu | ||
| 11 | chrome document shell, global bar, account menu, | ||
| 12 | command palette | ||
| 12 | 13 | repository header name, visibility, tab row | |
| 13 | 14 | panels and tables the bordered surfaces lists and pages sit on | |
| 14 | 15 | controls buttons, inputs, selects, form layout | |
| @@ -86,6 +87,11 @@ | |||
| 86 | 87 | /* One width for every search input: the global bar and a page's filter row | |
| 87 | 88 | should not resize the field when the reader moves between them. */ | |
| 88 | 89 | --search-w: 18rem; | |
| 90 | /* One measure for the page column and the global bar. The bar pads itself out | ||
| 91 | to the page's content edges, so the brand and the account menu sit on the | ||
| 92 | same vertical line as the content below them. */ | ||
| 93 | --page-max: 1440px; | ||
| 94 | --page-gutter: 24px; | ||
| 89 | 95 | --font: "Lexend", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "Liberation Sans", Arial, sans-serif; | |
| 90 | 96 | --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; | |
| 91 | 97 | --fs: 15.4px; | |
| @@ -198,7 +204,9 @@ img { max-width: 100%; } | |||
| 198 | 204 | align-items: center; | |
| 199 | 205 | gap: 20px; | |
| 200 | 206 | height: 46px; | |
| 201 | padding: 0 20px; | ||
| 207 | /* The bar spans the window; its items stay inside the page column. */ | ||
| 208 | padding-block: 0; | ||
| 209 | padding-inline: max(var(--page-gutter), calc((100% - var(--page-max)) / 2 + var(--page-gutter))); | ||
| 202 | 210 | background: color-mix(in srgb, var(--bg-panel) 92%, transparent); | |
| 203 | 211 | border-bottom: 1px solid var(--border); | |
| 204 | 212 | position: relative; | |
| @@ -253,9 +261,53 @@ img { max-width: 100%; } | |||
| 253 | 261 | color: var(--accent); | |
| 254 | 262 | } | |
| 255 | 263 | ||
| 256 | .topsearch { display: flex; align-items: center; gap: 6px; min-width: 0; margin-left: auto; } | ||
| 257 | .topsearch input[type="search"] { flex: 1 1 auto; width: var(--search-w); min-width: 8rem; max-width: 28vw; } | ||
| 258 | .topsearch button { flex: 0 0 auto; } | ||
| 264 | .topsearch { | ||
| 265 | flex: 1 1 auto; | ||
| 266 | min-width: 8rem; | ||
| 267 | max-width: var(--search-w); | ||
| 268 | margin-left: auto; | ||
| 269 | } | ||
| 270 | /* The reader is opening the palette, not editing a field, so this is a button | ||
| 271 | shaped like the search input it replaces. The label stands in for the | ||
| 272 | placeholder, and the last item names the shortcut the browser will hear. */ | ||
| 273 | .topsearch-box { | ||
| 274 | display: flex; | ||
| 275 | align-items: center; | ||
| 276 | gap: 8px; | ||
| 277 | width: 100%; | ||
| 278 | min-width: 0; | ||
| 279 | min-height: 30px; | ||
| 280 | padding: 5px 8px; | ||
| 281 | border: 1px solid var(--border-strong); | ||
| 282 | border-radius: var(--radius); | ||
| 283 | background: var(--bg); | ||
| 284 | color: var(--fg-muted); | ||
| 285 | font: inherit; | ||
| 286 | font-size: calc(13.75px * var(--font-scale)); | ||
| 287 | text-align: left; | ||
| 288 | cursor: pointer; | ||
| 289 | } | ||
| 290 | .topsearch-box:hover { | ||
| 291 | border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); | ||
| 292 | color: var(--fg); | ||
| 293 | } | ||
| 294 | .topsearch-box .icon { width: 13px; height: 13px; color: var(--fg-faint); } | ||
| 295 | .topsearch-label { | ||
| 296 | flex: 1 1 auto; | ||
| 297 | min-width: 0; | ||
| 298 | overflow: hidden; | ||
| 299 | text-overflow: ellipsis; | ||
| 300 | white-space: nowrap; | ||
| 301 | } | ||
| 302 | .topsearch-key { | ||
| 303 | flex: 0 0 auto; | ||
| 304 | padding: 0 4px; | ||
| 305 | border: 1px solid var(--border); | ||
| 306 | border-radius: 4px; | ||
| 307 | background: var(--bg-panel); | ||
| 308 | color: var(--fg-faint); | ||
| 309 | font-size: calc(11.55px * var(--font-scale)); | ||
| 310 | } | ||
| 259 | 311 | ||
| 260 | 312 | .topright { | |
| 261 | 313 | display: flex; | |
| @@ -320,28 +372,141 @@ img { max-width: 100%; } | |||
| 320 | 372 | .account-pop form { margin: 0; } | |
| 321 | 373 | .account-sep { border-top: 1px solid var(--border); margin: 4px 0; } | |
| 322 | 374 | ||
| 375 | /* ---------- command palette ---------- */ | ||
| 376 | |||
| 377 | /* The global bar's search opens this: one field over every issue, change and | ||
| 378 | comment the reader can see. A row is a link, so Enter and a click take the | ||
| 379 | same route; the stylesheet only has to make the selected row visible. */ | ||
| 380 | .palette { | ||
| 381 | width: min(40rem, calc(100vw - 2 * var(--page-gutter))); | ||
| 382 | max-height: min(80vh, 44rem); | ||
| 383 | margin: 10vh auto auto; | ||
| 384 | padding: 0; | ||
| 385 | border: 1px solid var(--border-strong); | ||
| 386 | border-radius: var(--radius-lg); | ||
| 387 | background: var(--bg-raised); | ||
| 388 | color: var(--fg); | ||
| 389 | box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45); | ||
| 390 | overflow: hidden; | ||
| 391 | } | ||
| 392 | .palette::backdrop { background: rgba(0, 0, 0, 0.5); } | ||
| 393 | .palette-search { | ||
| 394 | display: flex; | ||
| 395 | align-items: center; | ||
| 396 | gap: 10px; | ||
| 397 | padding: 10px 12px; | ||
| 398 | border-bottom: 1px solid var(--border); | ||
| 399 | } | ||
| 400 | .palette-search .icon { width: 16px; height: 16px; color: var(--fg-muted); } | ||
| 401 | .palette-search input[type="search"] { | ||
| 402 | flex: 1 1 auto; | ||
| 403 | width: auto; | ||
| 404 | max-width: none; | ||
| 405 | min-height: 0; | ||
| 406 | padding: 2px 0; | ||
| 407 | border: 0; | ||
| 408 | background: transparent; | ||
| 409 | font-size: calc(15.4px * var(--font-scale)); | ||
| 410 | } | ||
| 411 | /* The field is the dialog's heading, not a control with a box of its own. */ | ||
| 412 | .palette-search input[type="search"]:focus-visible { outline: 0; } | ||
| 413 | .palette-close { | ||
| 414 | display: inline-flex; | ||
| 415 | align-items: center; | ||
| 416 | justify-content: center; | ||
| 417 | padding: 4px; | ||
| 418 | border: 0; | ||
| 419 | border-radius: var(--radius); | ||
| 420 | background: none; | ||
| 421 | color: var(--fg-faint); | ||
| 422 | cursor: pointer; | ||
| 423 | } | ||
| 424 | .palette-close:hover { background: var(--bg-hover); color: var(--fg); } | ||
| 425 | .palette-results { max-height: min(60vh, 30rem); overflow-y: auto; padding: 6px; } | ||
| 426 | .palette-row { | ||
| 427 | display: block; | ||
| 428 | padding: 7px 9px; | ||
| 429 | border-radius: var(--radius); | ||
| 430 | color: var(--fg); | ||
| 431 | } | ||
| 432 | .palette-row:hover { background: var(--bg-hover); text-decoration: none; } | ||
| 433 | .palette-row.is-selected { background: var(--accent-soft); } | ||
| 434 | .palette-row-title { | ||
| 435 | display: block; | ||
| 436 | font-weight: 500; | ||
| 437 | overflow: hidden; | ||
| 438 | text-overflow: ellipsis; | ||
| 439 | white-space: nowrap; | ||
| 440 | } | ||
| 441 | .palette-row-where { | ||
| 442 | display: block; | ||
| 443 | color: var(--fg-faint); | ||
| 444 | font-size: calc(12.65px * var(--font-scale)); | ||
| 445 | } | ||
| 446 | /* A palette row is one line high per part; the editable page can afford the | ||
| 447 | wrapped excerpt, the dialog cannot. */ | ||
| 448 | .palette-row .search-snippet { | ||
| 449 | margin: 0; | ||
| 450 | overflow: hidden; | ||
| 451 | text-overflow: ellipsis; | ||
| 452 | white-space: nowrap; | ||
| 453 | } | ||
| 454 | .palette-all { | ||
| 455 | margin-top: 4px; | ||
| 456 | border-top: 1px solid var(--border); | ||
| 457 | border-radius: 0 0 var(--radius) var(--radius); | ||
| 458 | color: var(--accent); | ||
| 459 | font-size: calc(13.75px * var(--font-scale)); | ||
| 460 | } | ||
| 461 | .palette-empty { | ||
| 462 | margin: 0; | ||
| 463 | padding: 16px 12px; | ||
| 464 | color: var(--fg-muted); | ||
| 465 | font-size: calc(13.75px * var(--font-scale)); | ||
| 466 | } | ||
| 467 | .palette-foot { | ||
| 468 | display: flex; | ||
| 469 | gap: 14px; | ||
| 470 | padding: 7px 12px; | ||
| 471 | border-top: 1px solid var(--border); | ||
| 472 | color: var(--fg-faint); | ||
| 473 | font-size: calc(12.65px * var(--font-scale)); | ||
| 474 | } | ||
| 475 | .palette-foot kbd { | ||
| 476 | padding: 0 4px; | ||
| 477 | border: 1px solid var(--border); | ||
| 478 | border-radius: 4px; | ||
| 479 | background: var(--bg-panel); | ||
| 480 | color: var(--fg-muted); | ||
| 481 | font-size: calc(11.55px * var(--font-scale)); | ||
| 482 | } | ||
| 483 | |||
| 323 | 484 | .page { | |
| 324 | width: min(100%, 1440px); | ||
| 485 | width: min(100%, var(--page-max)); | ||
| 325 | 486 | margin: 0 auto; | |
| 326 | padding: 20px 24px 72px; | ||
| 487 | padding: 20px var(--page-gutter) 72px; | ||
| 327 | 488 | } | |
| 328 | 489 | ||
| 329 | 490 | @media (max-width: 720px) { | |
| 330 | 491 | .tabs { mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); } | |
| 492 | :root { --page-gutter: 12px; } | ||
| 331 | 493 | .topbar { | |
| 332 | 494 | height: auto; | |
| 333 | 495 | flex-wrap: wrap; | |
| 334 | padding: 8px 12px; | ||
| 496 | padding-block: 8px; | ||
| 335 | 497 | gap: 8px 12px; | |
| 336 | 498 | } | |
| 337 | .topsearch { order: 3; flex: 1 1 100%; margin-left: 0; } | ||
| 338 | .topsearch input[type="search"] { width: 100%; min-width: 0; max-width: none; } | ||
| 499 | .topsearch { order: 3; flex: 1 1 100%; max-width: none; margin-left: 0; } | ||
| 500 | .topsearch-box { width: 100%; } | ||
| 501 | /* A keyboard shortcut is not part of a phone's vocabulary. */ | ||
| 502 | .topsearch-key { display: none; } | ||
| 339 | 503 | .topright { margin-left: auto; } | |
| 340 | 504 | .topnav { gap: 4px; } | |
| 341 | 505 | .topnav a { padding: 4px 8px; } | |
| 342 | 506 | /* The icon is enough on a phone; the menu keeps the full name. */ | |
| 343 | 507 | .account-name { display: none; } | |
| 344 | .page { padding: 16px 12px 56px; } | ||
| 508 | .page { padding: 16px var(--page-gutter) 56px; } | ||
| 509 | .palette { margin-top: 6vh; } | ||
| 345 | 510 | } | |
| 346 | 511 | ||
| 347 | 512 | /* ---------- repository header ---------- */ | |