Skip to content
Open
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
47 changes: 47 additions & 0 deletions docs/superpowers/specs/2026-07-18-apps-improvements-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Apps feature — minor improvements design (2026-07-18)

Four small, independent improvements to the Apps feature. Each ships as its own commit.

## 1. Paste a GitHub URL with an embedded branch/tag → move it to the Revision box

When adding an app, a user often pastes a URL copied from GitHub's branch/tag view, e.g. `https://github.com/org/repo/tree/my-branch`. Today that whole string goes into the URL field and the Revision field stays empty; the ref survives only because `buildAppUrl` re-parses it later. We make the split explicit and visible.

`parseGithubUrl` (`frontend/src/utils/appUrls.ts`) already extracts `{owner, repo, branch}` from a `/tree/<ref>` URL. Add a one-line pure helper `splitGithubRef(url)` that returns `{ repoUrl, ref }` — the bare `https://github.com/owner/repo` plus the embedded ref (empty string when the URL carried no ref or the ref is `main`).

In `AddAppDialog.tsx`, the repo-URL field's `onChange` calls `splitGithubRef` on the new value; when it yields a non-empty ref, set the URL field to the bare repo URL and the Revision field to the ref. Only rewrites when the URL actually carried a `/tree/` ref, so typing a bare URL is undisturbed. A pasted URL's ref is authoritative, so it overwrites whatever is in the Revision box.

Test: unit test for `splitGithubRef` covering a bare URL, a `/tree/<branch>`, a `/tree/main`, a `.git` suffix, and a non-GitHub string.

## 2. Breadcrumbs on the app launch page

Replace the back-arrow + title portion of `AppPageHeader` on the launch page (`AppLaunch.tsx`) with a breadcrumb trail styled like the file-browser `Crumbs`: a leading apps-home icon, then `›` (HiChevronRight) delimited segments.

Trail: `[⊞ apps home → top page] › App Name → app-level page › [entry-point icon] Entry Point Name`. Before an entry point is selected it is just `[⊞] › App Name`. The description, GitHub URL line, and the launch-button actions slot are preserved below the breadcrumb.

Origin is inferred from install status (no URL/router plumbing):

- Installed: home → `/apps` (My Apps); App Name → the app detail page (`buildAppDetailPath`).
- Not installed: home → `/apps/catalog` (App Catalog); App Name → the matching catalog listing (`/apps/catalog/:id`), resolved from the already-cached `useCatalogQuery` by canonical url + manifest path. If no match is found, the App Name segment renders as plain text.

New component `frontend/src/components/ui/AppsPage/AppBreadcrumbs.tsx`. It is a thin flex row of `FgLink`/text + `FgIcon` separators — not a reuse of `BreadcrumbSegment`, whose separator is `/`, because the file-browser trail uses `›` between segments here.

## 3. Install count in the catalog

An "install" is a row in `user_apps`; there is no counter column and no association table. Count current installs live rather than denormalizing, so there is no migration and no counter to keep in sync. Semantics: how many users currently have the app installed.

Backend: add `install_count: int` to the `AppListing` Pydantic model (`fileglancer/model.py`). In `list_catalog` (`fileglancer/server.py`), run one grouped query over `user_apps` — `COUNT` grouped by `(url, manifest_path)` — and join it in memory to the listings, matching by canonical GitHub URL + manifest path (the same identity used elsewhere). A DB helper `count_installs_by_app(session)` returns the grouped counts. This is an O(listings) in-memory join, fine at catalog scale.

Frontend: add a sortable "Installs" column to `createCatalogColumns` (table view) and show the count on the catalog listing detail page via `ListingInfoTable`. `AppListing` in `shared.types.ts` gains `install_count`.

Interpretation note: "app detail page" is taken to mean the catalog listing detail page (`ListingDetail`), where the `AppListing` — and therefore the count — is available. The installed-app detail page (`AppDetail`) renders a `UserApp` with no count, so it is out of scope unless requested.

Test: backend pytest — seed a listing plus N `user_apps` rows for its url/manifest path across distinct users and assert `list_catalog` reports `install_count == N`.

## 4. Short commit display + rename to "Commit"

The Job page (`JobDetail.tsx`) shows a "Version" row: `commit_sha.slice(0, 7)` in `text-xs font-mono`, linked to the GitHub commit. The app page (`AppInfoTable.tsx`) shows the full untruncated SHA under an "App commit" label.

- App page: change `CommitValue` to render `sha.slice(0, 7)` in `text-xs font-mono` (matching the Job page) and rename the "App commit" label to "Commit". The "Code commit" row keeps its label but also renders short via the shared `CommitValue`.
- Job page: rename the "Version" label to "Commit".

No shared helper is extracted — `.slice(0, 7)` appears in two files and inlining is smaller than a util.
55 changes: 31 additions & 24 deletions fileglancer/apps/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
_WINDOWS_DRIVE_PATTERN,
)
from fileglancer.apps.jobfiles import _build_work_dir
from fileglancer.giturls import canonical_github_url
from fileglancer.giturls import canonical_github_url, same_github_repo
from fileglancer.model import AppEntryPoint
from fileglancer.settings import get_settings

Expand Down Expand Up @@ -591,11 +591,12 @@ def _container_bind_paths(entry_point, parameters: dict,
dangling. Cloud-storage URIs and non-absolute values are skipped — they
are not bind-mountable and would otherwise produce garbage binds.
2. The runnable's explicit `bind_paths`.
3. The cached repo clone, but only when the command runs from `repo`. The
`repo` symlink lives inside the (already-bound) work dir yet points at
the clone outside it, so without this bind the symlink dangles in the
container and the `cd` into it fails. Container runnables default to
`work`, so this is only added when the author opts into `repo`.
3. The cached repo clone, but only when the command runs from the clone
(`manifest` or `repo`). The `repo` symlink lives inside the
(already-bound) work dir yet points at the clone outside it, so without
this bind the symlink dangles in the container and the `cd` into it
fails. Container runnables default to `work`, so this is only added when
the author opts into `manifest` or `repo`.

The work dir itself is always bound by `_build_container_script`, so it is
not included here.
Expand All @@ -619,7 +620,7 @@ def _container_bind_paths(entry_point, parameters: dict,
bind_paths.append(str(PurePosixPath(expanded).parent))
if entry_point.bind_paths:
bind_paths.extend(entry_point.bind_paths)
if entry_point.effective_working_dir == "repo":
if entry_point.effective_working_dir != "work":
bind_paths.append(str(cached_repo_dir))
return bind_paths

Expand Down Expand Up @@ -796,12 +797,11 @@ async def submit_job(
# so it never drifts again. Pulling is never done here; updates are an
# explicit user action via the "Update" app endpoint.
executed_repo_url = None
if manifest.repo_url and canonical_github_url(manifest.repo_url) != stored_app_url:
# Manifest and tool code live in separate repos: the job runs from the
# code repo's snapshot root.
if manifest.repo_url and not same_github_repo(manifest.repo_url, stored_app_url):
# Manifest and tool code live in separate repos: the code repo is what
# gets cloned as `repo`.
cached_repo_dir, executed_sha = await ensure_repo_snapshot(
manifest.repo_url, sha=pinned_code_sha, username=username)
cd_suffix = "repo"
executed_repo_url = canonical_github_url(manifest.repo_url)
if app_installed and (pinned_sha is None or pinned_code_sha is None):
app_sha = pinned_sha
Expand All @@ -817,16 +817,19 @@ async def submit_job(
commit_sha=app_sha,
code_commit_sha=executed_sha)
else:
# Manifest and tool code share one repo: run from the subdirectory
# that contains the manifest.
# Manifest and tool code share one repo: it gets cloned as `repo`.
cached_repo_dir, executed_sha = await ensure_repo_snapshot(
app_clone_url, sha=pinned_sha, username=username)
cd_suffix = f"repo/{manifest_path}" if manifest_path else "repo"
if app_installed and pinned_sha is None:
with db.get_db_session(settings.db_url) as session:
db.set_user_app_pins(session, username, stored_app_url,
manifest_path, commit_sha=executed_sha)

# 'manifest' working_dir runs from the manifest's subdirectory within the
# clone; 'repo' runs from the clone root. The subdir is the same relative
# path regardless of whether the manifest and code share a repo.
manifest_suffix = f"repo/{manifest_path}" if manifest_path else "repo"

with db.get_db_session(settings.db_url) as session:
db_job = db.create_job(
session=session,
Expand Down Expand Up @@ -878,11 +881,11 @@ async def submit_job(

# Set up the script preamble:
# - FG_WORK_DIR: the job's working directory (used by subsequent variables)
# - FG_MANIFEST_DIR: the directory the job should treat as the app's project root
# (repo[/manifest_path] when the manifest lives with the code; otherwise
# the code repo root), so commands can reference it explicitly instead
# of relying on the cwd. Always points at the repo-side directory, even
# when effective_working_dir is "work" (the repo stays reachable there)
# - FG_MANIFEST_DIR: the manifest's directory inside the clone
# (repo[/manifest_path]), so commands can reference it explicitly
# instead of relying on the cwd. Always points at the repo-side
# directory, even when effective_working_dir is "work" (the repo stays
# reachable there)
# - SERVICE_URL_PATH: for service-type jobs, where to write the service URL
# - cd into the working directory chosen by effective_working_dir (see below);
# this sets where the task's command runs, not how tools locate the manifest
Expand All @@ -900,7 +903,7 @@ async def submit_job(
]
preamble_lines += [
f"export FG_WORK_DIR={shlex.quote(str(work_dir))}",
f'export FG_MANIFEST_DIR="$FG_WORK_DIR"/{shlex.quote(cd_suffix)}',
f'export FG_MANIFEST_DIR="$FG_WORK_DIR"/{shlex.quote(manifest_suffix)}',
# Where the script reports its startup phase (e.g. pulling a container
# image). The UI reads this to explain a wait before a service is ready.
'export FG_PHASE_PATH="$FG_WORK_DIR/phase"',
Expand Down Expand Up @@ -928,13 +931,17 @@ async def submit_job(
)
# Choose the working directory. 'work' runs from the job's work dir (the
# repo is still reachable via the `repo` symlink); 'repo' runs from the
# cloned project (optionally the manifest's subdirectory). cd_suffix may
# include a Git-derived directory name, so shell-escape it — FG_WORK_DIR
# stays in its own double-quoted segment so it still expands.
# cloned project's root; 'manifest' runs from the manifest's directory
# inside the clone (the repo root plus the manifest's subdirectory).
# manifest_suffix may include a Git-derived directory name, so
# shell-escape it — FG_WORK_DIR stays in its own double-quoted segment so
# it still expands.
if entry_point.effective_working_dir == "work":
preamble_lines.append('cd "$FG_WORK_DIR"')
elif entry_point.effective_working_dir == "repo":
preamble_lines.append('cd "$FG_WORK_DIR"/repo')
else:
preamble_lines.append(f'cd "$FG_WORK_DIR"/{shlex.quote(cd_suffix)}')
preamble_lines.append(f'cd "$FG_WORK_DIR"/{shlex.quote(manifest_suffix)}')
script_parts = ["\n".join(preamble_lines)]

# Conda environment activation
Expand Down
23 changes: 21 additions & 2 deletions fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
import os
from functools import lru_cache

from sqlalchemy import create_engine, Boolean, Column, String, Integer, DateTime, JSON, UniqueConstraint
from sqlalchemy import create_engine, Boolean, Column, String, Integer, DateTime, JSON, UniqueConstraint, func
from sqlalchemy.orm import sessionmaker, declarative_base, Session
from sqlalchemy.engine.url import make_url
from sqlalchemy.pool import StaticPool
from typing import Optional, Dict, List
from typing import Optional, Dict, List, Tuple
from loguru import logger
from cachetools import LRUCache

Expand Down Expand Up @@ -1269,6 +1269,25 @@ def get_app_listing(session: Session, listing_id: int) -> Optional[AppListingDB]
return session.query(AppListingDB).filter_by(id=listing_id).first()


def count_installs_by_app(session: Session) -> Dict[Tuple[str, str], int]:
"""Number of users who currently have each app installed, keyed by
(canonical url, manifest_path). The user_apps unique constraint on
(username, url, manifest_path) means one row per user, so a plain COUNT per
(url, manifest_path) is the distinct-user install count. Both user_apps and
app_listings store canonicalized URLs, so listings can look up their count
by exact (url, manifest_path)."""
rows = (
session.query(
UserAppDB.url,
UserAppDB.manifest_path,
func.count().label("n"),
)
.group_by(UserAppDB.url, UserAppDB.manifest_path)
.all()
)
return {(url, manifest_path): n for url, manifest_path, n in rows}


def get_app_listings_by_owner(session: Session, owner_username: str) -> List[AppListingDB]:
"""Get all app listings owned by a user."""
return session.query(AppListingDB).filter_by(owner_username=owner_username).all()
Expand Down
16 changes: 16 additions & 0 deletions fileglancer/giturls.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,19 @@ def github_url_with_branch(owner: str, repo: str, branch: str) -> str:
would mean "current default branch" and therefore could move over time.
"""
return f"https://github.com/{owner}/{repo}/tree/{branch}"


def same_github_repo(a: str, b: str) -> bool:
"""True if two GitHub URLs point at the same owner/repo, ignoring branch.

Used to tell "manifest and code live in different repositories" apart from
"same repo, different branch". Comparing canonical URLs conflates the two,
because a bare repo_url (no /tree/branch) never equals a stored app URL that
carries its branch. Returns False if either side isn't a parseable GitHub URL.
"""
try:
oa, ra, _ = _parse_github_url(a)
ob, rb, _ = _parse_github_url(b)
except ValueError:
return False
return (oa, ra) == (ob, rb)
17 changes: 9 additions & 8 deletions fileglancer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,12 +489,12 @@ class AppEntryPoint(BaseModel):
description="Default extra arguments for container exec (e.g. '--nv')",
default=None,
)
working_dir: Optional[Literal["work", "repo"]] = Field(
working_dir: Optional[Literal["work", "manifest", "repo"]] = Field(
description=(
"Directory the command runs from: 'repo' (the cloned project, "
"optionally the manifest's subdirectory) or 'work' (the job's work "
"directory). Defaults to 'work' for container entry points and "
"'repo' otherwise."
"Directory the command runs from: 'manifest' (the manifest's "
"directory inside the cloned project), 'repo' (the cloned project's "
"root), or 'work' (the job's work directory). Defaults to 'work' for "
"container entry points and 'manifest' otherwise."
),
default=None,
)
Expand Down Expand Up @@ -595,15 +595,15 @@ def validate_container_args(cls, v):

@property
def effective_working_dir(self) -> str:
"""Resolve where the command runs: 'work' or 'repo'.
"""Resolve where the command runs: 'work', 'manifest', or 'repo'.

An explicit working_dir wins; otherwise container entry points default
to 'work' (the repo clone isn't bind-mounted into the container, but the
work dir always is) and everything else defaults to 'repo'.
work dir always is) and everything else defaults to 'manifest'.
"""
if self.working_dir:
return self.working_dir
return "work" if self.container else "repo"
return "work" if self.container else "manifest"

def flat_parameters(self) -> List[AppParameter]:
"""Return a flat list of all parameters, traversing sections."""
Expand Down Expand Up @@ -780,6 +780,7 @@ class AppListing(BaseModel):
description: Optional[str] = Field(description="Description for the catalog", default=None)
published_at: datetime = Field(description="When this listing was published")
updated_at: Optional[datetime] = Field(description="When this listing was last edited", default=None)
install_count: int = Field(description="Number of users who currently have this app installed", default=0)


def validate_catalog_listing_name(name: Optional[str]) -> Optional[str]:
Expand Down
9 changes: 7 additions & 2 deletions fileglancer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2099,7 +2099,7 @@ async def validate_paths(body: PathValidationRequest,

# --- Catalog (shared apps) API ---

def _listing_to_model(row) -> AppListing:
def _listing_to_model(row, install_count: int = 0) -> AppListing:
return AppListing(
id=row.id,
owner_username=row.owner_username,
Expand All @@ -2110,14 +2110,19 @@ def _listing_to_model(row) -> AppListing:
description=row.description,
published_at=row.published_at,
updated_at=row.updated_at,
install_count=install_count,
)

@app.get("/api/catalog", response_model=list[AppListing],
description="List all shared app listings in the catalog")
async def list_catalog(username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
rows = db.list_app_listings(session)
return [_listing_to_model(r) for r in rows]
counts = db.count_installs_by_app(session)
return [
_listing_to_model(r, counts.get((r.url, r.manifest_path), 0))
for r in rows
]

@app.post("/api/catalog", response_model=AppListing,
description="Share one of the user's apps to the catalog")
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/__tests__/componentTests/AppBreadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { IoTerminal } from 'react-icons/io5';

import AppBreadcrumbs from '@/components/ui/AppsPage/AppBreadcrumbs';

function renderCrumbs(props: React.ComponentProps<typeof AppBreadcrumbs>) {
return render(
<MemoryRouter>
<AppBreadcrumbs {...props} />
</MemoryRouter>
);
}

describe('AppBreadcrumbs', () => {
it('links home and app-name, and renders the entry point as a non-link leaf', () => {
renderCrumbs({
homeTo: '/apps',
homeLabel: 'My Apps',
appName: 'Demo App',
appTo: '/apps/detail/org/repo',
entryPointName: 'Run',
entryPointIcon: IoTerminal
});

// Leading icon links back to the top apps page (named by its sr-only label).
expect(screen.getByRole('link', { name: 'My Apps' })).toHaveAttribute(
'href',
'/apps'
);
// App name links to the detail page.
expect(screen.getByRole('link', { name: 'Demo App' })).toHaveAttribute(
'href',
'/apps/detail/org/repo'
);
// Entry point is the current, non-clickable leaf.
expect(screen.getByText('Run')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Run' })).toBeNull();
});

it('renders the app name as plain text when no detail link is available', () => {
renderCrumbs({
homeTo: '/apps/catalog',
homeLabel: 'App Catalog',
appName: 'Uninstalled App'
});

expect(screen.getByRole('link', { name: 'App Catalog' })).toHaveAttribute(
'href',
'/apps/catalog'
);
expect(screen.getByText('Uninstalled App')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Uninstalled App' })).toBeNull();
});
});
Loading
Loading