The repository view: a file table and an about rail

e533d85a04f6 · AtlantisPleb · · parent 23f6f22625a0

The repository view: a file table and an about rail

Our repo page is a heading, a commit list, a refs list and a README. It has no
file table at all, which is the thing a code host's repository page mostly is.

Two components, adapted from the clones catalogued in
`docs/audits/2026-08-19-github-clone-harvest-candidates.md`: `gh-next` (MIT,
Fredkiss3/gh-next) for the Tailwind repo home's header and rail, and Gitea's
`view_list.tmpl` for what a tree row must carry.

`file_table/1` takes what `Browse.tree/3` already returns. Directories sort
above files, which is what makes a deep repository scannable. The latest
commit sits above the table rather than inside it: it describes the tree as a
whole, and a row that describes the table while looking like a row in it is
the part of GitHub's own version that reads oddly at first glance. Links
follow GitHub's grammar -- `/:owner/:repo/tree/:ref/*path` for a directory,
`blob` for a file -- which the harvest audit treats as a compatibility target
rather than a style choice, so a bookmark or a `gh`-shaped tool keeps working.

Per-row commit messages are absent on purpose. GitHub fills them by walking
history once per path, which is one git process per file. The row has a slot
for it when that is cheap.

`repo_about/1` is the rail. Every row is optional and an absent one renders
nothing, because a rail that says "no description" is louder than one that
does not mention it. The language breakdown is a single bar rather than a
stack: proportions only read as proportions when the segments share a length,
and the colours are a fixed six-step rotation off the ink ladder instead of a
hue hashed per language name.

Both catalogued and demoed. Neither is wired into `CodeRepoLive` yet -- that
needs a tree route and a ref picker, which is its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/ui.ex
  • modified lib/openagents_web/live/components_live.ex

Diff

4 files changed, +516 -0

assets/css/openagents.css modified +249

@@ -4919,3 +4919,252 @@

4919 4919
    margin: 0;
4920 4920
  }
4921 4921
}
4922
4923
/* ── Repository view ──────────────────────────────────────────────────────── */
4924
4925
/* The GitHub-shaped repo home: a ref bar, the latest commit, the tree, and a
4926
 * rail describing the repository. Adapted from the clones catalogued in
4927
 * docs/audits/2026-08-19-github-clone-harvest-candidates.md -- gh-next (MIT)
4928
 * for the Tailwind repo home, Gitea's view_list.tmpl for what a tree row
4929
 * carries. Values are ours. */
4930
4931
@layer components {
4932
  .repo-view {
4933
    display: grid;
4934
    align-items: start;
4935
    grid-template-columns: minmax(0, 1fr);
4936
    gap: 24px;
4937
  }
4938
4939
  @media (min-width: 1024px) {
4940
    .repo-view {
4941
      grid-template-columns: minmax(0, 1fr) 296px;
4942
    }
4943
  }
4944
4945
  /* ── File table ─────────────────────────────────────────────────────────── */
4946
4947
  .file-table {
4948
    overflow: hidden;
4949
    border: 1px solid var(--line);
4950
    border-radius: var(--radius-lg);
4951
  }
4952
4953
  .file-table__bar {
4954
    display: flex;
4955
    flex-wrap: wrap;
4956
    align-items: center;
4957
    gap: 8px 16px;
4958
    padding: 12px;
4959
    background: var(--ink-surface);
4960
    font-size: 0.8125rem;
4961
  }
4962
4963
  .file-table__ref {
4964
    display: inline-flex;
4965
    align-items: center;
4966
    gap: 6px;
4967
    border: 1px solid var(--line-strong);
4968
    border-radius: 6px;
4969
    padding: 4px 10px;
4970
    color: var(--text-primary);
4971
    font-weight: 500;
4972
  }
4973
4974
  .file-table__count {
4975
    color: var(--text-muted);
4976
  }
4977
4978
  .file-table__count strong {
4979
    color: var(--text-primary);
4980
    font-variant-numeric: tabular-nums;
4981
  }
4982
4983
  .file-table__actions {
4984
    display: flex;
4985
    align-items: center;
4986
    gap: 8px;
4987
    margin-inline-start: auto;
4988
  }
4989
4990
  /* Above the table, not inside it. This row describes the whole tree, and a
4991
     row that describes the table while looking like a row in it is the part of
4992
     GitHub's own version that reads oddly at first glance. */
4993
  .file-table__commit {
4994
    display: flex;
4995
    flex-wrap: wrap;
4996
    align-items: center;
4997
    gap: 8px 12px;
4998
    border-block: 1px solid var(--line);
4999
    background: var(--ink-raised);
5000
    padding: 10px 12px;
5001
    color: var(--text-muted);
5002
    font-size: 0.8125rem;
5003
  }
5004
5005
  .file-table__list {
5006
    width: 100%;
5007
    border-collapse: collapse;
5008
    font-size: 0.875rem;
5009
  }
5010
5011
  .file-row + .file-row {
5012
    border-top: 1px solid var(--line-faint);
5013
  }
5014
5015
  .file-row td {
5016
    padding: 8px 12px;
5017
  }
5018
5019
  .file-row__link {
5020
    display: inline-flex;
5021
    align-items: center;
5022
    gap: 10px;
5023
    color: var(--text-body);
5024
    text-decoration: none;
5025
  }
5026
5027
  /* A folder leads, so it is tinted; a file is the default and is not. Tinting
5028
     both would mean tinting nothing. */
5029
  .file-row[data-kind="tree"] .file-row__link .icon {
5030
    color: var(--info);
5031
  }
5032
5033
  .file-row .icon {
5034
    width: 16px;
5035
    height: 16px;
5036
    flex: none;
5037
    color: var(--icon-tertiary);
5038
    font-size: 16px;
5039
  }
5040
5041
  @media (hover: hover) {
5042
    .file-row__link:hover {
5043
      color: var(--text-primary);
5044
      text-decoration: underline;
5045
      text-underline-offset: 2px;
5046
    }
5047
  }
5048
5049
  .file-row__size {
5050
    width: 1%;
5051
    color: var(--text-dim);
5052
    font-size: 0.75rem;
5053
    font-variant-numeric: tabular-nums;
5054
    text-align: end;
5055
    white-space: nowrap;
5056
  }
5057
5058
  .file-table__empty {
5059
    padding: 16px 12px;
5060
    color: var(--text-dim);
5061
    font-size: 0.875rem;
5062
  }
5063
5064
  /* ── About rail ─────────────────────────────────────────────────────────── */
5065
5066
  .repo-about {
5067
    display: flex;
5068
    flex-direction: column;
5069
    gap: 16px;
5070
  }
5071
5072
  .repo-about__title {
5073
    color: var(--text-primary);
5074
    font-size: 1rem;
5075
    font-weight: 600;
5076
  }
5077
5078
  .repo-about__description {
5079
    color: var(--text-muted);
5080
    font-size: 0.9375rem;
5081
    line-height: 1.5;
5082
    text-wrap: pretty;
5083
  }
5084
5085
  .repo-about__links,
5086
  .repo-about__stats,
5087
  .repo-about__language-list {
5088
    display: flex;
5089
    flex-direction: column;
5090
    gap: 8px;
5091
    margin: 0;
5092
    padding: 0;
5093
    list-style: none;
5094
  }
5095
5096
  .repo-about__links li,
5097
  .repo-about__stats li,
5098
  .repo-about__language-list li {
5099
    display: flex;
5100
    align-items: center;
5101
    gap: 8px;
5102
    color: var(--text-muted);
5103
    font-size: 0.875rem;
5104
  }
5105
5106
  .repo-about__links a {
5107
    display: inline-flex;
5108
    align-items: center;
5109
    gap: 8px;
5110
    color: var(--text-muted);
5111
    text-decoration: none;
5112
  }
5113
5114
  @media (hover: hover) {
5115
    .repo-about__links a:hover {
5116
      color: var(--text-primary);
5117
    }
5118
  }
5119
5120
  .repo-about .icon {
5121
    width: 16px;
5122
    height: 16px;
5123
    flex: none;
5124
    color: var(--icon-tertiary);
5125
    font-size: 16px;
5126
  }
5127
5128
  .repo-about__languages h3 {
5129
    padding-block-end: 8px;
5130
    color: var(--text-primary);
5131
    font-size: 0.875rem;
5132
    font-weight: 600;
5133
  }
5134
5135
  /* One bar rather than a stack: the proportions are the point, and they only
5136
     read as proportions when the segments share a length. */
5137
  .language-bar {
5138
    display: flex;
5139
    height: 8px;
5140
    overflow: hidden;
5141
    border-radius: 999px;
5142
    background: var(--line);
5143
  }
5144
5145
  .language-bar__segment {
5146
    height: 100%;
5147
  }
5148
5149
  .language-dot {
5150
    width: 10px;
5151
    height: 10px;
5152
    flex: none;
5153
    border-radius: 50%;
5154
  }
5155
5156
  /* A fixed rotation rather than a hash of the name: six steps off the ink
5157
     ladder, so the chart stays in the palette and two adjacent languages are
5158
     always distinguishable. */
5159
  .language-bar__segment[data-index="0"], .language-dot[data-index="0"] { background: var(--text-primary); }
5160
  .language-bar__segment[data-index="1"], .language-dot[data-index="1"] { background: var(--info); }
5161
  .language-bar__segment[data-index="2"], .language-dot[data-index="2"] { background: var(--success); }
5162
  .language-bar__segment[data-index="3"], .language-dot[data-index="3"] { background: var(--warning); }
5163
  .language-bar__segment[data-index="4"], .language-dot[data-index="4"] { background: var(--text-muted); }
5164
  .language-bar__segment[data-index="5"], .language-dot[data-index="5"] { background: var(--text-dim); }
5165
5166
  .repo-about__percent {
5167
    color: var(--text-dim);
5168
    font-variant-numeric: tabular-nums;
5169
  }
5170
}
lib/openagents_web/component_catalog.ex modified +14

@@ -65,6 +65,20 @@ defmodule OpenAgentsWeb.ComponentCatalog do

65 65
          source: "OpenAgentsWeb.UI.breadcrumb/1",
66 66
          summary: "Ancestor trail ending in the current page, which is not a link."
67 67
        },
68
        %{
69
          slug: "openagents-file-table",
70
          title: "File table",
71
          icon: "folder",
72
          source: "OpenAgentsWeb.UI.file_table/1",
73
          summary: "A repository tree: ref bar, latest commit, and entries."
74
        },
75
        %{
76
          slug: "openagents-repo-about",
77
          title: "Repo about",
78
          icon: "info",
79
          source: "OpenAgentsWeb.UI.repo_about/1",
80
          summary: "The rail beside a repository: description, licence, languages."
81
        },
68 82
        %{
69 83
          slug: "openagents-diff-file",
70 84
          title: "Diff",
lib/openagents_web/components/ui.ex modified +170

@@ -818,6 +818,176 @@ defmodule OpenAgentsWeb.UI do

818 818
    """
819 819
  end
820 820
821
  @doc """
822
  A repository's file table: the ref bar, the latest commit, and the entries.
823
824
  Adapted from the GitHub-shaped clones catalogued in
825
  `docs/audits/2026-08-19-github-clone-harvest-candidates.md` -- `gh-next`
826
  (MIT, Fredkiss3/gh-next) for the Tailwind repo home's header and rail, and
827
  Gitea's `view_list.tmpl` for what a tree row must actually carry.
828
829
  Directories sort above files, which is what `Browse.tree/3` already returns
830
  and what makes a deep repository scannable: a reader looks for the folder
831
  first and only then for the file.
832
833
  The latest-commit bar sits above the table rather than inside it. It
834
  describes the tree as a whole, and a row that describes the whole table but
835
  looks like a row is the reason GitHub's own version of this reads oddly on
836
  first sight.
837
838
  Per-row commit messages are deliberately absent. GitHub fills them by
839
  walking history once per path, which is one process per file; when we have
840
  that cheaply the slot is `meta` and it costs no markup change.
841
  """
842
  attr :owner, :string, required: true
843
  attr :repo, :string, required: true
844
  attr :ref, :string, required: true
845
  attr :path, :string, default: ""
846
  attr :entries, :list, required: true, doc: "`[%{name, kind, size}]` from `Browse.tree/3`"
847
  attr :branches, :integer, default: nil
848
  attr :tags, :integer, default: nil
849
  attr :class, :any, default: nil
850
  slot :commit, doc: "the latest commit, shown above the table"
851
  slot :actions, doc: "controls at the trailing edge of the ref bar"
852
853
  def file_table(assigns) do
854
    ~H"""
855
    <div class={["file-table", @class]}>
856
      <div class="file-table__bar">
857
        <span class="file-table__ref">
858
          <.icon name="branch" /> {@ref}
859
        </span>
860
        <span :if={@branches} class="file-table__count">
861
          <strong>{@branches}</strong> {plural(@branches, "Branch", "Branches")}
862
        </span>
863
        <span :if={@tags} class="file-table__count">
864
          <strong>{@tags}</strong> {plural(@tags, "Tag", "Tags")}
865
        </span>
866
        <span :if={@actions != []} class="file-table__actions">{render_slot(@actions)}</span>
867
      </div>
868
869
      <div :if={@commit != []} class="file-table__commit">{render_slot(@commit)}</div>
870
871
      <table class="file-table__list">
872
        <caption class="visually-hidden">
873
          Files in {(@path == "" && "the repository root") || @path}
874
        </caption>
875
        <tbody>
876
          <tr :for={entry <- @entries} class="file-row" data-kind={entry.kind}>
877
            <td class="file-row__name">
878
              <.link navigate={entry_path(@owner, @repo, @ref, @path, entry)} class="file-row__link">
879
                <.icon name={if entry.kind == "tree", do: "folder", else: "file-document"} />
880
                {entry.name}
881
              </.link>
882
            </td>
883
            <td class="file-row__size">{size_label(entry)}</td>
884
          </tr>
885
        </tbody>
886
      </table>
887
888
      <p :if={@entries == []} class="file-table__empty">Nothing at this path.</p>
889
    </div>
890
    """
891
  end
892
893
  # A directory goes to `tree`, a file to `blob`. GitHub's grammar, which the
894
  # harvest audit treats as the compatibility target rather than a style
895
  # choice: an existing link, bookmark, or `gh`-shaped tool should keep working.
896
  defp entry_path(owner, repo, ref, path, %{kind: kind, name: name}) do
897
    noun = if kind == "tree", do: "tree", else: "blob"
898
    joined = if path == "", do: name, else: path <> "/" <> name
899
    "/#{owner}/#{repo}/#{noun}/#{ref}/#{joined}"
900
  end
901
902
  # `UI` is not a gettext backend, and these two words are the only plurals it
903
  # needs.
904
  defp plural(1, singular, _plural), do: singular
905
  defp plural(_count, _singular, plural), do: plural
906
907
  defp size_label(%{kind: "tree"}), do: nil
908
  defp size_label(%{size: nil}), do: nil
909
910
  defp size_label(%{size: bytes}) when is_integer(bytes) do
911
    cond do
912
      bytes < 1_024 -> "#{bytes} B"
913
      bytes < 1_048_576 -> "#{Float.round(bytes / 1_024, 1)} KB"
914
      true -> "#{Float.round(bytes / 1_048_576, 1)} MB"
915
    end
916
  end
917
918
  defp size_label(_entry), do: nil
919
920
  @doc """
921
  The rail beside a repository: what it is, how it is licensed, what it is made of.
922
923
  Adapted from `gh-next`'s repo home. Every row is optional, and an absent one
924
  renders nothing rather than a placeholder -- a rail that says "no description"
925
  is louder than one that simply does not mention it.
926
  """
927
  attr :description, :string, default: nil
928
  attr :license, :string, default: nil
929
  attr :class, :any, default: nil
930
931
  slot :link, doc: "one related destination" do
932
    attr :icon, :string
933
    attr :navigate, :string
934
    attr :href, :string
935
  end
936
937
  slot :stat, doc: "one count" do
938
    attr :icon, :string
939
  end
940
941
  slot :language, doc: "one language" do
942
    attr :percent, :float, required: true
943
  end
944
945
  def repo_about(assigns) do
946
    ~H"""
947
    <aside class={["repo-about", @class]} aria-label="About this repository">
948
      <h2 class="repo-about__title">About</h2>
949
      <p :if={@description} class="repo-about__description">{@description}</p>
950
951
      <ul :if={@link != [] or @license} class="repo-about__links">
952
        <li :if={@license}>
953
          <.icon name="scales" /> {@license}
954
        </li>
955
        <li :for={link <- @link}>
956
          <.link navigate={link[:navigate]} href={link[:href]}>
957
            <.icon :if={link[:icon]} name={link.icon} /> {render_slot(link)}
958
          </.link>
959
        </li>
960
      </ul>
961
962
      <ul :if={@stat != []} class="repo-about__stats">
963
        <li :for={stat <- @stat}>
964
          <.icon :if={stat[:icon]} name={stat.icon} /> {render_slot(stat)}
965
        </li>
966
      </ul>
967
968
      <div :if={@language != []} class="repo-about__languages">
969
        <h3>Languages</h3>
970
        <%!-- One bar, not a stack of bars: the proportions are the point, and
971
        they only read as proportions when they share a length. --%>
972
        <div class="language-bar" role="img" aria-label="Language breakdown">
973
          <span
974
            :for={{language, index} <- Enum.with_index(@language)}
975
            class="language-bar__segment"
976
            data-index={rem(index, 6)}
977
            style={"width: #{language.percent}%"}
978
          ></span>
979
        </div>
980
        <ul class="repo-about__language-list">
981
          <li :for={{language, index} <- Enum.with_index(@language)}>
982
            <span class="language-dot" data-index={rem(index, 6)} aria-hidden="true"></span>
983
            {render_slot(language)} <span class="repo-about__percent">{language.percent}%</span>
984
          </li>
985
        </ul>
986
      </div>
987
    </aside>
988
    """
989
  end
990
821 991
  @doc """
822 992
  One file's diff: a header, its hunks, and every line numbered on both sides.
823 993
lib/openagents_web/live/components_live.ex modified +83

@@ -958,6 +958,89 @@ defmodule OpenAgentsWeb.ComponentsLive do

958 958
    """
959 959
  end
960 960
961
  defp component_demo(%{item: %{slug: "openagents-file-table"}} = assigns) do
962
    assigns =
963
      assign(assigns, :entries, [
964
        %{name: "assets", kind: "tree", size: nil},
965
        %{name: "config", kind: "tree", size: nil},
966
        %{name: "lib", kind: "tree", size: nil},
967
        %{name: "priv", kind: "tree", size: nil},
968
        %{name: "test", kind: "tree", size: nil},
969
        %{name: ".formatter.exs", kind: "blob", size: 412},
970
        %{name: "mix.exs", kind: "blob", size: 4_318},
971
        %{name: "README.md", kind: "blob", size: 1_204_291}
972
      ])
973
974
    ~H"""
975
    <div class="space-y-3">
976
      <p class="text-sm text-base-content/60">
977
        Takes what <code>Browse.tree/3</code> already returns. Directories sort above
978
        files, which is what makes a deep repository scannable: a reader looks for the
979
        folder first and only then for the file.
980
      </p>
981
      <p class="text-sm text-base-content/60">
982
        The latest commit sits above the table, not inside it. It describes the tree as
983
        a whole, and a row that describes the table while looking like a row in it is
984
        the part of GitHub's own version that reads oddly at first glance.
985
      </p>
986
      <UI.file_table
987
        owner="OpenAgentsInc"
988
        repo="openagents.com"
989
        ref="main"
990
        entries={@entries}
991
        branches={2}
992
        tags={0}
993
      >
994
        <:actions>
995
          <UI.button size={:sm}>Add file</UI.button>
996
          <UI.button variant={:primary} size={:sm}>Code</UI.button>
997
        </:actions>
998
        <:commit>
999
          <strong>AtlantisPleb</strong>
1000
          <span>The commit page shows a diff instead of printing one</span>
1001
          <code>57d5fd9</code>
1002
          <span>15 minutes ago</span>
1003
        </:commit>
1004
      </UI.file_table>
1005
      <p class="text-sm text-base-content/60">
1006
        Per-row commit messages are deliberately absent: GitHub fills them by walking
1007
        history once per path, which is one git process per file. When that is cheap the
1008
        row has a slot for it and the markup does not change.
1009
      </p>
1010
    </div>
1011
    """
1012
  end
1013
1014
  defp component_demo(%{item: %{slug: "openagents-repo-about"}} = assigns) do
1015
    ~H"""
1016
    <div class="space-y-3">
1017
      <p class="text-sm text-base-content/60">
1018
        Every row is optional and an absent one renders nothing. A rail that says
1019
        "no description" is louder than one that simply does not mention it.
1020
      </p>
1021
      <div class="max-w-xs">
1022
        <UI.repo_about description="The Agent Forge" license="AGPL-3.0">
1023
          <:link icon="book" navigate="/docs">Readme</:link>
1024
          <:link icon="text" navigate="/changelog">Activity</:link>
1025
          <:stat icon="star">2 stars</:stat>
1026
          <:stat icon="eye">0 watching</:stat>
1027
          <:language percent={91.8}>Elixir</:language>
1028
          <:language percent={3.1}>CSS</:language>
1029
          <:language percent={3.1}>Shell</:language>
1030
          <:language percent={1.0}>JavaScript</:language>
1031
          <:language percent={1.0}>Other</:language>
1032
        </UI.repo_about>
1033
      </div>
1034
      <p class="text-sm text-base-content/60">
1035
        The language breakdown is one bar rather than a stack of them: the proportions
1036
        are the point, and they only read as proportions when the segments share a
1037
        length. Colours are a fixed six-step rotation off the ink ladder, so the chart
1038
        stays in the palette instead of hashing a hue per language name.
1039
      </p>
1040
    </div>
1041
    """
1042
  end
1043
961 1044
  defp component_demo(%{item: %{slug: "openagents-diff-file"}} = assigns) do
962 1045
    assigns =
963 1046
      assign(

This page updates live while a promote is in flight · changelog