mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-27 02:22:41 +00:00
Compare commits
4 Commits
jamison/od
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66891d19e | ||
|
|
c07c952ad5 | ||
|
|
be7f40a28a | ||
|
|
26f941b5da |
@@ -24,6 +24,16 @@ When hardcoding a boolean variable to a constant value, remove the variable enti
|
||||
|
||||
Code changes must consider both multi-tenant and single-tenant deployments. In multi-tenant mode, preserve tenant isolation, ensure tenant context is propagated correctly, and avoid assumptions that only hold for a single shared schema or globally shared state. In single-tenant mode, avoid introducing unnecessary tenant-specific requirements or cloud-only control-plane dependencies.
|
||||
|
||||
## Nginx Routing — New Backend Routes
|
||||
|
||||
Whenever a new backend route is added that does NOT start with `/api`, it must also be explicitly added to ALL nginx configs:
|
||||
- `deployment/helm/charts/onyx/templates/nginx-conf.yaml` (Helm/k8s)
|
||||
- `deployment/data/nginx/app.conf.template` (docker-compose dev)
|
||||
- `deployment/data/nginx/app.conf.template.prod` (docker-compose prod)
|
||||
- `deployment/data/nginx/app.conf.template.no-letsencrypt` (docker-compose no-letsencrypt)
|
||||
|
||||
Routes not starting with `/api` are not caught by the existing `^/(api|openapi\.json)` location block and will fall through to `location /`, which proxies to the Next.js web server and returns an HTML 404. The new location block must be placed before the `/api` block. Examples of routes that need this treatment: `/scim`, `/mcp`.
|
||||
|
||||
## Full vs Lite Deployments
|
||||
|
||||
Code changes must consider both regular Onyx deployments and Onyx lite deployments. Lite deployments disable the vector DB, Redis, model servers, and background workers by default, use PostgreSQL-backed cache/auth/file storage, and rely on the API server to handle background work. Do not assume those services are available unless the code path is explicitly limited to full deployments.
|
||||
|
||||
@@ -473,6 +473,8 @@ def connector_permission_sync_generator_task(
|
||||
cc_pair = get_connector_credential_pair_from_id(
|
||||
db_session=db_session,
|
||||
cc_pair_id=cc_pair_id,
|
||||
eager_load_connector=True,
|
||||
eager_load_credential=True,
|
||||
)
|
||||
if cc_pair is None:
|
||||
raise ValueError(
|
||||
|
||||
@@ -8,6 +8,7 @@ from ee.onyx.external_permissions.slack.utils import fetch_user_id_to_email_map
|
||||
from onyx.access.models import DocExternalAccess
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.connectors.credentials_provider import OnyxDBCredentialsProvider
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from onyx.connectors.slack.connector import get_channels
|
||||
from onyx.connectors.slack.connector import make_paginated_slack_api_call
|
||||
@@ -105,9 +106,11 @@ def _get_slack_document_access(
|
||||
slack_connector: SlackConnector,
|
||||
channel_permissions: dict[str, ExternalAccess], # noqa: ARG001
|
||||
callback: IndexingHeartbeatInterface | None,
|
||||
indexing_start: SecondsSinceUnixEpoch | None = None,
|
||||
) -> Generator[DocExternalAccess, None, None]:
|
||||
slim_doc_generator = slack_connector.retrieve_all_slim_docs_perm_sync(
|
||||
callback=callback
|
||||
callback=callback,
|
||||
start=indexing_start,
|
||||
)
|
||||
|
||||
for doc_metadata_batch in slim_doc_generator:
|
||||
@@ -180,9 +183,15 @@ def slack_doc_sync(
|
||||
|
||||
slack_connector = SlackConnector(**cc_pair.connector.connector_specific_config)
|
||||
slack_connector.set_credentials_provider(provider)
|
||||
indexing_start_ts: SecondsSinceUnixEpoch | None = (
|
||||
cc_pair.connector.indexing_start.timestamp()
|
||||
if cc_pair.connector.indexing_start is not None
|
||||
else None
|
||||
)
|
||||
|
||||
yield from _get_slack_document_access(
|
||||
slack_connector,
|
||||
slack_connector=slack_connector,
|
||||
channel_permissions=channel_permissions,
|
||||
callback=callback,
|
||||
indexing_start=indexing_start_ts,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from onyx.access.models import ElementExternalAccess
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.access.models import NodeExternalAccess
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
@@ -40,10 +41,19 @@ def generic_doc_sync(
|
||||
|
||||
logger.info(f"Starting {doc_source} doc sync for CC Pair ID: {cc_pair.id}")
|
||||
|
||||
indexing_start: SecondsSinceUnixEpoch | None = (
|
||||
cc_pair.connector.indexing_start.timestamp()
|
||||
if cc_pair.connector.indexing_start is not None
|
||||
else None
|
||||
)
|
||||
|
||||
newly_fetched_doc_ids: set[str] = set()
|
||||
|
||||
logger.info(f"Fetching all slim documents from {doc_source}")
|
||||
for doc_batch in slim_connector.retrieve_all_slim_docs_perm_sync(callback=callback):
|
||||
for doc_batch in slim_connector.retrieve_all_slim_docs_perm_sync(
|
||||
start=indexing_start,
|
||||
callback=callback,
|
||||
):
|
||||
logger.info(f"Got {len(doc_batch)} slim documents from {doc_source}")
|
||||
|
||||
if callback:
|
||||
|
||||
@@ -890,8 +890,8 @@ class ConfluenceConnector(
|
||||
|
||||
def _retrieve_all_slim_docs(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
|
||||
end: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
|
||||
start: SecondsSinceUnixEpoch | None = None,
|
||||
end: SecondsSinceUnixEpoch | None = None,
|
||||
callback: IndexingHeartbeatInterface | None = None,
|
||||
include_permissions: bool = True,
|
||||
) -> GenerateSlimDocumentOutput:
|
||||
@@ -915,8 +915,8 @@ class ConfluenceConnector(
|
||||
self.confluence_client, doc_id, restrictions, ancestors
|
||||
) or space_level_access_info.get(page_space_key)
|
||||
|
||||
# Query pages
|
||||
page_query = self.base_cql_page_query + self.cql_label_filter
|
||||
# Query pages (with optional time filtering for indexing_start)
|
||||
page_query = self._construct_page_cql_query(start, end)
|
||||
for page in self.confluence_client.cql_paginate_all_expansions(
|
||||
cql=page_query,
|
||||
expand=restrictions_expand,
|
||||
@@ -950,7 +950,9 @@ class ConfluenceConnector(
|
||||
|
||||
# Query attachments for each page
|
||||
page_hierarchy_node_yielded = False
|
||||
attachment_query = self._construct_attachment_query(_get_page_id(page))
|
||||
attachment_query = self._construct_attachment_query(
|
||||
_get_page_id(page), start, end
|
||||
)
|
||||
for attachment in self.confluence_client.cql_paginate_all_expansions(
|
||||
cql=attachment_query,
|
||||
expand=restrictions_expand,
|
||||
|
||||
@@ -1765,7 +1765,11 @@ class SharepointConnector(
|
||||
checkpoint.current_drive_delta_next_link = None
|
||||
checkpoint.seen_document_ids.clear()
|
||||
|
||||
def _fetch_slim_documents_from_sharepoint(self) -> GenerateSlimDocumentOutput:
|
||||
def _fetch_slim_documents_from_sharepoint(
|
||||
self,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
) -> GenerateSlimDocumentOutput:
|
||||
site_descriptors = self._filter_excluded_sites(
|
||||
self.site_descriptors or self.fetch_sites()
|
||||
)
|
||||
@@ -1786,7 +1790,9 @@ class SharepointConnector(
|
||||
# Process site documents if flag is True
|
||||
if self.include_site_documents:
|
||||
for driveitem, drive_name, drive_web_url in self._fetch_driveitems(
|
||||
site_descriptor=site_descriptor
|
||||
site_descriptor=site_descriptor,
|
||||
start=start,
|
||||
end=end,
|
||||
):
|
||||
if self._is_driveitem_excluded(driveitem):
|
||||
logger.debug(f"Excluding by path denylist: {driveitem.web_url}")
|
||||
@@ -1841,7 +1847,9 @@ class SharepointConnector(
|
||||
|
||||
# Process site pages if flag is True
|
||||
if self.include_site_pages:
|
||||
site_pages = self._fetch_site_pages(site_descriptor)
|
||||
site_pages = self._fetch_site_pages(
|
||||
site_descriptor, start=start, end=end
|
||||
)
|
||||
for site_page in site_pages:
|
||||
logger.debug(
|
||||
f"Processing site page: {site_page.get('webUrl', site_page.get('name', 'Unknown'))}"
|
||||
@@ -2565,12 +2573,22 @@ class SharepointConnector(
|
||||
|
||||
def retrieve_all_slim_docs_perm_sync(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
|
||||
end: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
|
||||
start: SecondsSinceUnixEpoch | None = None,
|
||||
end: SecondsSinceUnixEpoch | None = None,
|
||||
callback: IndexingHeartbeatInterface | None = None, # noqa: ARG002
|
||||
) -> GenerateSlimDocumentOutput:
|
||||
|
||||
yield from self._fetch_slim_documents_from_sharepoint()
|
||||
start_dt = (
|
||||
datetime.fromtimestamp(start, tz=timezone.utc)
|
||||
if start is not None
|
||||
else None
|
||||
)
|
||||
end_dt = (
|
||||
datetime.fromtimestamp(end, tz=timezone.utc) if end is not None else None
|
||||
)
|
||||
yield from self._fetch_slim_documents_from_sharepoint(
|
||||
start=start_dt,
|
||||
end=end_dt,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -516,6 +516,8 @@ def _get_all_doc_ids(
|
||||
] = default_msg_filter,
|
||||
callback: IndexingHeartbeatInterface | None = None,
|
||||
workspace_url: str | None = None,
|
||||
start: SecondsSinceUnixEpoch | None = None,
|
||||
end: SecondsSinceUnixEpoch | None = None,
|
||||
) -> GenerateSlimDocumentOutput:
|
||||
"""
|
||||
Get all document ids in the workspace, channel by channel
|
||||
@@ -546,6 +548,8 @@ def _get_all_doc_ids(
|
||||
client=client,
|
||||
channel=channel,
|
||||
callback=callback,
|
||||
oldest=str(start) if start else None, # 0.0 -> None intentionally
|
||||
latest=str(end) if end is not None else None,
|
||||
)
|
||||
|
||||
for message_batch in channel_message_batches:
|
||||
@@ -847,8 +851,8 @@ class SlackConnector(
|
||||
|
||||
def retrieve_all_slim_docs_perm_sync(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
|
||||
end: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
|
||||
start: SecondsSinceUnixEpoch | None = None,
|
||||
end: SecondsSinceUnixEpoch | None = None,
|
||||
callback: IndexingHeartbeatInterface | None = None,
|
||||
) -> GenerateSlimDocumentOutput:
|
||||
if self.client is None:
|
||||
@@ -861,6 +865,8 @@ class SlackConnector(
|
||||
msg_filter_func=self.msg_filter_func,
|
||||
callback=callback,
|
||||
workspace_url=self._workspace_url,
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
|
||||
def _load_from_checkpoint(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,6 +19,10 @@ PRIVATE_CHANNEL_USERS = [
|
||||
"test_user_2@onyx-test.com",
|
||||
]
|
||||
|
||||
# Predates any test workspace messages, so the result set should match
|
||||
# the "no start time" case while exercising the oldest= parameter.
|
||||
OLDEST_TS_2016 = datetime(2016, 1, 1, tzinfo=timezone.utc).timestamp()
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("enable_ee")
|
||||
|
||||
|
||||
@@ -105,15 +111,17 @@ def test_load_from_checkpoint_access__private_channel(
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@pytest.mark.parametrize("start_ts", [None, OLDEST_TS_2016])
|
||||
def test_slim_documents_access__public_channel(
|
||||
slack_connector: SlackConnector,
|
||||
start_ts: float | None,
|
||||
) -> None:
|
||||
"""Test that retrieve_all_slim_docs_perm_sync returns correct access information for slim documents."""
|
||||
if not slack_connector.client:
|
||||
raise RuntimeError("Web client must be defined")
|
||||
|
||||
slim_docs_generator = slack_connector.retrieve_all_slim_docs_perm_sync(
|
||||
start=0.0,
|
||||
start=start_ts,
|
||||
end=time.time(),
|
||||
)
|
||||
|
||||
@@ -149,7 +157,7 @@ def test_slim_documents_access__private_channel(
|
||||
raise RuntimeError("Web client must be defined")
|
||||
|
||||
slim_docs_generator = slack_connector.retrieve_all_slim_docs_perm_sync(
|
||||
start=0.0,
|
||||
start=None,
|
||||
end=time.time(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -31,6 +33,7 @@ def mock_jira_cc_pair(
|
||||
"jira_base_url": jira_base_url,
|
||||
"project_key": project_key,
|
||||
}
|
||||
mock_cc_pair.connector.indexing_start = None
|
||||
|
||||
return mock_cc_pair
|
||||
|
||||
@@ -65,3 +68,75 @@ def test_jira_permission_sync(
|
||||
fetch_all_existing_docs_ids_fn=mock_fetch_all_existing_docs_ids_fn,
|
||||
):
|
||||
print(doc)
|
||||
|
||||
|
||||
def test_jira_doc_sync_passes_indexing_start(
|
||||
jira_connector: JiraConnector,
|
||||
mock_jira_cc_pair: MagicMock,
|
||||
mock_fetch_all_existing_docs_fn: MagicMock,
|
||||
mock_fetch_all_existing_docs_ids_fn: MagicMock,
|
||||
) -> None:
|
||||
"""Verify that generic_doc_sync derives indexing_start from cc_pair
|
||||
and forwards it to retrieve_all_slim_docs_perm_sync."""
|
||||
indexing_start_dt = datetime(2025, 6, 1, tzinfo=timezone.utc)
|
||||
mock_jira_cc_pair.connector.indexing_start = indexing_start_dt
|
||||
|
||||
with patch("onyx.connectors.jira.connector.build_jira_client") as mock_build_client:
|
||||
mock_build_client.return_value = jira_connector._jira_client
|
||||
assert jira_connector._jira_client is not None
|
||||
jira_connector._jira_client._options = MagicMock()
|
||||
jira_connector._jira_client._options.return_value = {
|
||||
"rest_api_version": JIRA_SERVER_API_VERSION
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
type(jira_connector),
|
||||
"retrieve_all_slim_docs_perm_sync",
|
||||
return_value=iter([]),
|
||||
) as mock_retrieve:
|
||||
list(
|
||||
jira_doc_sync(
|
||||
cc_pair=mock_jira_cc_pair,
|
||||
fetch_all_existing_docs_fn=mock_fetch_all_existing_docs_fn,
|
||||
fetch_all_existing_docs_ids_fn=mock_fetch_all_existing_docs_ids_fn,
|
||||
)
|
||||
)
|
||||
|
||||
mock_retrieve.assert_called_once()
|
||||
call_kwargs = mock_retrieve.call_args
|
||||
assert call_kwargs.kwargs["start"] == indexing_start_dt.timestamp()
|
||||
|
||||
|
||||
def test_jira_doc_sync_passes_none_when_no_indexing_start(
|
||||
jira_connector: JiraConnector,
|
||||
mock_jira_cc_pair: MagicMock,
|
||||
mock_fetch_all_existing_docs_fn: MagicMock,
|
||||
mock_fetch_all_existing_docs_ids_fn: MagicMock,
|
||||
) -> None:
|
||||
"""Verify that indexing_start is None when the connector has no indexing_start set."""
|
||||
mock_jira_cc_pair.connector.indexing_start = None
|
||||
|
||||
with patch("onyx.connectors.jira.connector.build_jira_client") as mock_build_client:
|
||||
mock_build_client.return_value = jira_connector._jira_client
|
||||
assert jira_connector._jira_client is not None
|
||||
jira_connector._jira_client._options = MagicMock()
|
||||
jira_connector._jira_client._options.return_value = {
|
||||
"rest_api_version": JIRA_SERVER_API_VERSION
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
type(jira_connector),
|
||||
"retrieve_all_slim_docs_perm_sync",
|
||||
return_value=iter([]),
|
||||
) as mock_retrieve:
|
||||
list(
|
||||
jira_doc_sync(
|
||||
cc_pair=mock_jira_cc_pair,
|
||||
fetch_all_existing_docs_fn=mock_fetch_all_existing_docs_fn,
|
||||
fetch_all_existing_docs_ids_fn=mock_fetch_all_existing_docs_ids_fn,
|
||||
)
|
||||
)
|
||||
|
||||
mock_retrieve.assert_called_once()
|
||||
call_kwargs = mock_retrieve.call_args
|
||||
assert call_kwargs.kwargs["start"] is None
|
||||
|
||||
@@ -39,6 +39,22 @@ server {
|
||||
# Conditionally include MCP location configuration
|
||||
include /etc/nginx/conf.d/mcp.conf.inc;
|
||||
|
||||
location ~ ^/scim(/.*)?$ {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_redirect off;
|
||||
proxy_connect_timeout ${NGINX_PROXY_CONNECT_TIMEOUT}s;
|
||||
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT}s;
|
||||
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT}s;
|
||||
proxy_pass http://api_server;
|
||||
}
|
||||
|
||||
# Match both /api/* and /openapi.json in a single rule
|
||||
location ~ ^/(api|openapi.json)(/.*)?$ {
|
||||
# Rewrite /api prefixed matched paths
|
||||
|
||||
@@ -39,6 +39,20 @@ server {
|
||||
# Conditionally include MCP location configuration
|
||||
include /etc/nginx/conf.d/mcp.conf.inc;
|
||||
|
||||
location ~ ^/scim(/.*)?$ {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# don't trust client-supplied X-Forwarded-* headers — use nginx's own values
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://api_server;
|
||||
}
|
||||
|
||||
# Match both /api/* and /openapi.json in a single rule
|
||||
location ~ ^/(api|openapi.json)(/.*)?$ {
|
||||
# Rewrite /api prefixed matched paths
|
||||
|
||||
@@ -39,6 +39,23 @@ server {
|
||||
# Conditionally include MCP location configuration
|
||||
include /etc/nginx/conf.d/mcp.conf.inc;
|
||||
|
||||
location ~ ^/scim(/.*)?$ {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# don't trust client-supplied X-Forwarded-* headers — use nginx's own values
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_redirect off;
|
||||
proxy_connect_timeout ${NGINX_PROXY_CONNECT_TIMEOUT}s;
|
||||
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT}s;
|
||||
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT}s;
|
||||
proxy_pass http://api_server;
|
||||
}
|
||||
|
||||
# Match both /api/* and /openapi.json in a single rule
|
||||
location ~ ^/(api|openapi.json)(/.*)?$ {
|
||||
# Rewrite /api prefixed matched paths
|
||||
|
||||
@@ -5,7 +5,7 @@ home: https://www.onyx.app/
|
||||
sources:
|
||||
- "https://github.com/onyx-dot-app/onyx"
|
||||
type: application
|
||||
version: 0.4.36
|
||||
version: 0.4.37
|
||||
appVersion: latest
|
||||
annotations:
|
||||
category: Productivity
|
||||
|
||||
@@ -63,6 +63,22 @@ data:
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
location ~ ^/scim(/.*)?$ {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_redirect off;
|
||||
# timeout settings
|
||||
proxy_connect_timeout {{ .Values.nginx.timeouts.connect }}s;
|
||||
proxy_send_timeout {{ .Values.nginx.timeouts.send }}s;
|
||||
proxy_read_timeout {{ .Values.nginx.timeouts.read }}s;
|
||||
proxy_pass http://api_server;
|
||||
}
|
||||
|
||||
location ~ ^/(api|openapi\.json)(/.*)?$ {
|
||||
rewrite ^/api(/.*)$ $1 break;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -282,7 +282,7 @@ nginx:
|
||||
# The ingress-nginx subchart doesn't auto-detect our custom ConfigMap changes.
|
||||
# Workaround: Helm upgrade will restart if the following annotation value changes.
|
||||
podAnnotations:
|
||||
onyx.app/nginx-config-version: "2"
|
||||
onyx.app/nginx-config-version: "3"
|
||||
|
||||
# Propagate DOMAIN into nginx so server_name continues to use the same env var
|
||||
extraEnvs:
|
||||
|
||||
@@ -28,7 +28,7 @@ Some commands require external tools to be installed and configured:
|
||||
- **uv** - Required for `backend` commands
|
||||
- Install from [docs.astral.sh/uv](https://docs.astral.sh/uv/)
|
||||
|
||||
- **GitHub CLI** (`gh`) - Required for `run-ci`, `cherry-pick`, and `trace` commands
|
||||
- **GitHub CLI** (`gh`) - Required for `run-ci` and `cherry-pick` commands
|
||||
- Install from [cli.github.com](https://cli.github.com/)
|
||||
- Authenticate with `gh auth login`
|
||||
|
||||
@@ -412,62 +412,6 @@ The `compare` subcommand writes a `summary.json` alongside the report with aggre
|
||||
counts (changed, added, removed, unchanged). The HTML report is only generated when
|
||||
visual differences are detected.
|
||||
|
||||
### `trace` - View Playwright Traces from CI
|
||||
|
||||
Download Playwright trace artifacts from a GitHub Actions run and open them
|
||||
with `playwright show-trace`. Traces are only generated for failing tests
|
||||
(`retain-on-failure`).
|
||||
|
||||
```shell
|
||||
ods trace [run-id-or-url]
|
||||
```
|
||||
|
||||
The run can be specified as a numeric run ID, a full GitHub Actions URL, or
|
||||
omitted to find the latest Playwright run for the current branch.
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--branch`, `-b` | | Find latest run for this branch |
|
||||
| `--pr` | | Find latest run for this PR number |
|
||||
| `--project`, `-p` | | Filter to a specific project (`admin`, `exclusive`, `lite`) |
|
||||
| `--list`, `-l` | `false` | List available traces without opening |
|
||||
| `--no-open` | `false` | Download traces but don't open them |
|
||||
|
||||
When multiple traces are found, an interactive picker lets you select which
|
||||
traces to open. Use arrow keys or `j`/`k` to navigate, `space` to toggle,
|
||||
`a` to select all, `n` to deselect all, and `enter` to open. Falls back to a
|
||||
plain-text prompt when no TTY is available.
|
||||
|
||||
Downloaded artifacts are cached in `/tmp/ods-traces/<run-id>/` so repeated
|
||||
invocations for the same run are instant.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```shell
|
||||
# Latest run for the current branch
|
||||
ods trace
|
||||
|
||||
# Specific run ID
|
||||
ods trace 12345678
|
||||
|
||||
# Full GitHub Actions URL
|
||||
ods trace https://github.com/onyx-dot-app/onyx/actions/runs/12345678
|
||||
|
||||
# Latest run for a PR
|
||||
ods trace --pr 9500
|
||||
|
||||
# Latest run for a specific branch
|
||||
ods trace --branch main
|
||||
|
||||
# Only download admin project traces
|
||||
ods trace --project admin
|
||||
|
||||
# List traces without opening
|
||||
ods trace --list
|
||||
```
|
||||
|
||||
### Testing Changes Locally (Dry Run)
|
||||
|
||||
Both `run-ci` and `cherry-pick` support `--dry-run` to test without making remote changes:
|
||||
|
||||
@@ -55,7 +55,6 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(NewWebCommand())
|
||||
cmd.AddCommand(NewLatestStableTagCommand())
|
||||
cmd.AddCommand(NewWhoisCommand())
|
||||
cmd.AddCommand(NewTraceCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/tools/ods/internal/git"
|
||||
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
|
||||
"github.com/onyx-dot-app/onyx/tools/ods/internal/tui"
|
||||
)
|
||||
|
||||
const playwrightWorkflow = "Run Playwright Tests"
|
||||
|
||||
// TraceOptions holds options for the trace command
|
||||
type TraceOptions struct {
|
||||
Branch string
|
||||
PR string
|
||||
Project string
|
||||
List bool
|
||||
NoOpen bool
|
||||
}
|
||||
|
||||
// traceInfo describes a single trace.zip found in the downloaded artifacts.
|
||||
type traceInfo struct {
|
||||
Path string // absolute path to trace.zip
|
||||
Project string // project group extracted from artifact dir (e.g. "admin", "admin-shard-1")
|
||||
TestDir string // test directory name (human-readable-ish)
|
||||
}
|
||||
|
||||
// NewTraceCommand creates a new trace command
|
||||
func NewTraceCommand() *cobra.Command {
|
||||
opts := &TraceOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "trace [run-id-or-url]",
|
||||
Short: "Download and view Playwright traces from GitHub Actions",
|
||||
Long: `Download Playwright trace artifacts from a GitHub Actions run and open them
|
||||
with 'playwright show-trace'.
|
||||
|
||||
The run can be specified as:
|
||||
- A GitHub Actions run ID (numeric)
|
||||
- A full GitHub Actions run URL
|
||||
- Omitted, to find the latest Playwright run for the current branch
|
||||
|
||||
You can also look up the latest run by branch name or PR number.
|
||||
|
||||
Examples:
|
||||
ods trace # latest run for current branch
|
||||
ods trace 12345678 # specific run ID
|
||||
ods trace https://github.com/onyx-dot-app/onyx/actions/runs/12345678
|
||||
ods trace --pr 9500 # latest run for PR #9500
|
||||
ods trace --branch main # latest run for main branch
|
||||
ods trace --project admin # only download admin project traces
|
||||
ods trace --list # list available traces without opening`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runTrace(args, opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Find latest run for this branch")
|
||||
cmd.Flags().StringVar(&opts.PR, "pr", "", "Find latest run for this PR number")
|
||||
cmd.Flags().StringVarP(&opts.Project, "project", "p", "", "Filter to a specific project (admin, exclusive, lite)")
|
||||
cmd.Flags().BoolVarP(&opts.List, "list", "l", false, "List available traces without opening")
|
||||
cmd.Flags().BoolVar(&opts.NoOpen, "no-open", false, "Download traces but don't open them")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ghRun represents a GitHub Actions workflow run from `gh run list`
|
||||
type ghRun struct {
|
||||
DatabaseID int64 `json:"databaseId"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
HeadBranch string `json:"headBranch"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func runTrace(args []string, opts *TraceOptions) {
|
||||
git.CheckGitHubCLI()
|
||||
|
||||
runID, err := resolveRunID(args, opts)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve run: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("Using run ID: %s", runID)
|
||||
|
||||
destDir, err := downloadTraceArtifacts(runID, opts.Project)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to download artifacts: %v", err)
|
||||
}
|
||||
|
||||
traces, err := findTraceInfos(destDir, runID)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to find traces: %v", err)
|
||||
}
|
||||
|
||||
if len(traces) == 0 {
|
||||
log.Info("No trace files found in the downloaded artifacts.")
|
||||
log.Info("Traces are only generated for failing tests (retain-on-failure).")
|
||||
return
|
||||
}
|
||||
|
||||
projects := groupByProject(traces)
|
||||
|
||||
if opts.List || opts.NoOpen {
|
||||
printTraceList(traces, projects)
|
||||
fmt.Printf("\nTraces downloaded to: %s\n", destDir)
|
||||
return
|
||||
}
|
||||
|
||||
if len(traces) == 1 {
|
||||
openTraces(traces)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
selected := selectTraces(traces, projects)
|
||||
if len(selected) == 0 {
|
||||
return
|
||||
}
|
||||
openTraces(selected)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRunID determines the run ID from the provided arguments and options.
|
||||
func resolveRunID(args []string, opts *TraceOptions) (string, error) {
|
||||
if len(args) == 1 {
|
||||
return parseRunIDFromArg(args[0])
|
||||
}
|
||||
|
||||
if opts.PR != "" {
|
||||
return findLatestRunForPR(opts.PR)
|
||||
}
|
||||
|
||||
branch := opts.Branch
|
||||
if branch == "" {
|
||||
var err error
|
||||
branch, err = git.GetCurrentBranch()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current branch: %w", err)
|
||||
}
|
||||
if branch == "" {
|
||||
return "", fmt.Errorf("detached HEAD; specify a --branch, --pr, or run ID")
|
||||
}
|
||||
log.Infof("Using current branch: %s", branch)
|
||||
}
|
||||
|
||||
return findLatestRunForBranch(branch)
|
||||
}
|
||||
|
||||
var runURLPattern = regexp.MustCompile(`/actions/runs/(\d+)`)
|
||||
|
||||
// parseRunIDFromArg extracts a run ID from either a numeric string or a full URL.
|
||||
func parseRunIDFromArg(arg string) (string, error) {
|
||||
if matched, _ := regexp.MatchString(`^\d+$`, arg); matched {
|
||||
return arg, nil
|
||||
}
|
||||
|
||||
matches := runURLPattern.FindStringSubmatch(arg)
|
||||
if matches != nil {
|
||||
return matches[1], nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not parse run ID from %q; expected a numeric ID or GitHub Actions URL", arg)
|
||||
}
|
||||
|
||||
// findLatestRunForBranch finds the most recent Playwright workflow run for a branch.
|
||||
func findLatestRunForBranch(branch string) (string, error) {
|
||||
log.Infof("Looking up latest Playwright run for branch: %s", branch)
|
||||
|
||||
cmd := exec.Command("gh", "run", "list",
|
||||
"--workflow", playwrightWorkflow,
|
||||
"--branch", branch,
|
||||
"--limit", "1",
|
||||
"--json", "databaseId,status,conclusion,headBranch,url",
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", ghError(err, "gh run list failed")
|
||||
}
|
||||
|
||||
var runs []ghRun
|
||||
if err := json.Unmarshal(output, &runs); err != nil {
|
||||
return "", fmt.Errorf("failed to parse run list: %w", err)
|
||||
}
|
||||
|
||||
if len(runs) == 0 {
|
||||
return "", fmt.Errorf("no Playwright runs found for branch %q", branch)
|
||||
}
|
||||
|
||||
run := runs[0]
|
||||
log.Infof("Found run: %s (status: %s, conclusion: %s)", run.URL, run.Status, run.Conclusion)
|
||||
return fmt.Sprintf("%d", run.DatabaseID), nil
|
||||
}
|
||||
|
||||
// findLatestRunForPR finds the most recent Playwright workflow run for a PR.
|
||||
func findLatestRunForPR(prNumber string) (string, error) {
|
||||
log.Infof("Looking up branch for PR #%s", prNumber)
|
||||
|
||||
cmd := exec.Command("gh", "pr", "view", prNumber,
|
||||
"--json", "headRefName",
|
||||
"--jq", ".headRefName",
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", ghError(err, "gh pr view failed")
|
||||
}
|
||||
|
||||
branch := strings.TrimSpace(string(output))
|
||||
if branch == "" {
|
||||
return "", fmt.Errorf("could not determine branch for PR #%s", prNumber)
|
||||
}
|
||||
|
||||
log.Infof("PR #%s is on branch: %s", prNumber, branch)
|
||||
return findLatestRunForBranch(branch)
|
||||
}
|
||||
|
||||
// downloadTraceArtifacts downloads playwright trace artifacts for a run.
|
||||
// Returns the path to the download directory.
|
||||
func downloadTraceArtifacts(runID string, project string) (string, error) {
|
||||
cacheKey := runID
|
||||
if project != "" {
|
||||
cacheKey = runID + "-" + project
|
||||
}
|
||||
destDir := filepath.Join(os.TempDir(), "ods-traces", cacheKey)
|
||||
|
||||
// Reuse a previous download if traces exist
|
||||
if info, err := os.Stat(destDir); err == nil && info.IsDir() {
|
||||
traces, _ := findTraces(destDir)
|
||||
if len(traces) > 0 {
|
||||
log.Infof("Using cached download at %s", destDir)
|
||||
return destDir, nil
|
||||
}
|
||||
_ = os.RemoveAll(destDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||
}
|
||||
|
||||
ghArgs := []string{"run", "download", runID, "--dir", destDir}
|
||||
|
||||
if project != "" {
|
||||
ghArgs = append(ghArgs, "--pattern", fmt.Sprintf("playwright-test-results-%s-*", project))
|
||||
} else {
|
||||
ghArgs = append(ghArgs, "--pattern", "playwright-test-results-*")
|
||||
}
|
||||
|
||||
log.Infof("Downloading trace artifacts...")
|
||||
log.Debugf("Running: gh %s", strings.Join(ghArgs, " "))
|
||||
|
||||
cmd := exec.Command("gh", ghArgs...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
_ = os.RemoveAll(destDir)
|
||||
return "", fmt.Errorf("gh run download failed: %w\nMake sure the run ID is correct and the artifacts haven't expired (30 day retention)", err)
|
||||
}
|
||||
|
||||
return destDir, nil
|
||||
}
|
||||
|
||||
// findTraces recursively finds all trace.zip files under a directory.
|
||||
func findTraces(root string) ([]string, error) {
|
||||
var traces []string
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && info.Name() == "trace.zip" {
|
||||
traces = append(traces, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return traces, err
|
||||
}
|
||||
|
||||
// findTraceInfos walks the download directory and returns structured trace info.
|
||||
// Expects: destDir/{artifact-dir}/{test-dir}/trace.zip
|
||||
func findTraceInfos(destDir, runID string) ([]traceInfo, error) {
|
||||
var traces []traceInfo
|
||||
err := filepath.Walk(destDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() || info.Name() != "trace.zip" {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(destDir, path)
|
||||
parts := strings.SplitN(rel, string(filepath.Separator), 3)
|
||||
|
||||
artifactDir := ""
|
||||
testDir := filepath.Base(filepath.Dir(path))
|
||||
if len(parts) >= 2 {
|
||||
artifactDir = parts[0]
|
||||
testDir = parts[1]
|
||||
}
|
||||
|
||||
traces = append(traces, traceInfo{
|
||||
Path: path,
|
||||
Project: extractProject(artifactDir, runID),
|
||||
TestDir: testDir,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.Slice(traces, func(i, j int) bool {
|
||||
pi, pj := projectSortKey(traces[i].Project), projectSortKey(traces[j].Project)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return traces[i].TestDir < traces[j].TestDir
|
||||
})
|
||||
|
||||
return traces, err
|
||||
}
|
||||
|
||||
// extractProject derives a project group from an artifact directory name.
|
||||
// e.g. "playwright-test-results-admin-12345" -> "admin"
|
||||
//
|
||||
// "playwright-test-results-admin-shard-1-12345" -> "admin-shard-1"
|
||||
func extractProject(artifactDir, runID string) string {
|
||||
name := strings.TrimPrefix(artifactDir, "playwright-test-results-")
|
||||
name = strings.TrimSuffix(name, "-"+runID)
|
||||
if name == "" {
|
||||
return artifactDir
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// projectSortKey returns a sort-friendly key that orders admin < exclusive < lite.
|
||||
func projectSortKey(project string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(project, "admin"):
|
||||
return "0-" + project
|
||||
case strings.HasPrefix(project, "exclusive"):
|
||||
return "1-" + project
|
||||
case strings.HasPrefix(project, "lite"):
|
||||
return "2-" + project
|
||||
default:
|
||||
return "3-" + project
|
||||
}
|
||||
}
|
||||
|
||||
// groupByProject returns an ordered list of unique project names found in traces.
|
||||
func groupByProject(traces []traceInfo) []string {
|
||||
seen := map[string]bool{}
|
||||
var projects []string
|
||||
for _, t := range traces {
|
||||
if !seen[t.Project] {
|
||||
seen[t.Project] = true
|
||||
projects = append(projects, t.Project)
|
||||
}
|
||||
}
|
||||
sort.Slice(projects, func(i, j int) bool {
|
||||
return projectSortKey(projects[i]) < projectSortKey(projects[j])
|
||||
})
|
||||
return projects
|
||||
}
|
||||
|
||||
// printTraceList displays traces grouped by project.
|
||||
func printTraceList(traces []traceInfo, projects []string) {
|
||||
fmt.Printf("\nFound %d trace(s) across %d project(s):\n", len(traces), len(projects))
|
||||
|
||||
idx := 1
|
||||
for _, proj := range projects {
|
||||
count := 0
|
||||
for _, t := range traces {
|
||||
if t.Project == proj {
|
||||
count++
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n %s (%d):\n", proj, count)
|
||||
for _, t := range traces {
|
||||
if t.Project == proj {
|
||||
fmt.Printf(" [%2d] %s\n", idx, t.TestDir)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// selectTraces tries the TUI picker first, falling back to a plain-text
|
||||
// prompt when the terminal cannot be initialised (e.g. piped output).
|
||||
func selectTraces(traces []traceInfo, projects []string) []traceInfo {
|
||||
// Build picker groups in the same order as the sorted traces slice.
|
||||
var groups []tui.PickerGroup
|
||||
for _, proj := range projects {
|
||||
var items []string
|
||||
for _, t := range traces {
|
||||
if t.Project == proj {
|
||||
items = append(items, t.TestDir)
|
||||
}
|
||||
}
|
||||
groups = append(groups, tui.PickerGroup{Label: proj, Items: items})
|
||||
}
|
||||
|
||||
indices, err := tui.Pick(groups)
|
||||
if err != nil {
|
||||
// Terminal not available — fall back to text prompt
|
||||
log.Debugf("TUI picker unavailable: %v", err)
|
||||
printTraceList(traces, projects)
|
||||
return promptTraceSelection(traces, projects)
|
||||
}
|
||||
if indices == nil {
|
||||
return nil // user cancelled
|
||||
}
|
||||
|
||||
selected := make([]traceInfo, len(indices))
|
||||
for i, idx := range indices {
|
||||
selected[i] = traces[idx]
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// promptTraceSelection asks the user which traces to open via plain text.
|
||||
// Accepts numbers (1,3,5), ranges (1-5), "all", or a project name.
|
||||
func promptTraceSelection(traces []traceInfo, projects []string) []traceInfo {
|
||||
fmt.Printf("\nOpen which traces? (e.g. 1,3,5 | 1-5 | all | %s): ", strings.Join(projects, " | "))
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read input: %v", err)
|
||||
}
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
if input == "" || strings.EqualFold(input, "all") {
|
||||
return traces
|
||||
}
|
||||
|
||||
// Check if input matches a project name
|
||||
for _, proj := range projects {
|
||||
if strings.EqualFold(input, proj) {
|
||||
var selected []traceInfo
|
||||
for _, t := range traces {
|
||||
if t.Project == proj {
|
||||
selected = append(selected, t)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
// Parse as number/range selection
|
||||
indices := parseTraceSelection(input, len(traces))
|
||||
if len(indices) == 0 {
|
||||
log.Warn("No valid selection; opening all traces")
|
||||
return traces
|
||||
}
|
||||
|
||||
selected := make([]traceInfo, len(indices))
|
||||
for i, idx := range indices {
|
||||
selected[i] = traces[idx]
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// parseTraceSelection parses a comma-separated list of numbers and ranges into
|
||||
// 0-based indices. Input is 1-indexed (matches display). Out-of-range values
|
||||
// are silently ignored.
|
||||
func parseTraceSelection(input string, max int) []int {
|
||||
var result []int
|
||||
seen := map[int]bool{}
|
||||
|
||||
for _, part := range strings.Split(input, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx := strings.Index(part, "-"); idx > 0 {
|
||||
lo, err1 := strconv.Atoi(strings.TrimSpace(part[:idx]))
|
||||
hi, err2 := strconv.Atoi(strings.TrimSpace(part[idx+1:]))
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
for i := lo; i <= hi; i++ {
|
||||
zi := i - 1
|
||||
if zi >= 0 && zi < max && !seen[zi] {
|
||||
result = append(result, zi)
|
||||
seen[zi] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
zi := n - 1
|
||||
if zi >= 0 && zi < max && !seen[zi] {
|
||||
result = append(result, zi)
|
||||
seen[zi] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// openTraces opens the selected traces with playwright show-trace,
|
||||
// running npx from the web/ directory to use the project's Playwright version.
|
||||
func openTraces(traces []traceInfo) {
|
||||
tracePaths := make([]string, len(traces))
|
||||
for i, t := range traces {
|
||||
tracePaths[i] = t.Path
|
||||
}
|
||||
|
||||
args := append([]string{"playwright", "show-trace"}, tracePaths...)
|
||||
|
||||
log.Infof("Opening %d trace(s) with playwright show-trace...", len(traces))
|
||||
cmd := exec.Command("npx", args...)
|
||||
|
||||
// Run from web/ to pick up the locally-installed Playwright version
|
||||
if root, err := paths.GitRoot(); err == nil {
|
||||
cmd.Dir = filepath.Join(root, "web")
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
// Normal exit (e.g. user closed the window) — just log and return
|
||||
// so the picker loop can continue.
|
||||
log.Debugf("playwright exited with code %d", exitErr.ExitCode())
|
||||
return
|
||||
}
|
||||
log.Errorf("playwright show-trace failed: %v\nMake sure Playwright is installed (npx playwright install)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ghError wraps a gh CLI error with stderr output.
|
||||
func ghError(err error, msg string) error {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return fmt.Errorf("%s: %w: %s", msg, err, string(exitErr.Stderr))
|
||||
}
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
@@ -3,19 +3,13 @@ module github.com/onyx-dot-app/onyx/tools/ods
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/jmelahman/tag v0.5.2
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,68 +1,30 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jmelahman/tag v0.5.2 h1:g6A/aHehu5tkA31mPoDsXBNr1FigZ9A82Y8WVgb/WsM=
|
||||
github.com/jmelahman/tag v0.5.2/go.mod h1:qmuqk19B1BKkpcg3kn7l/Eey+UqucLxgOWkteUGiG4Q=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// PickerGroup represents a labelled group of selectable items.
|
||||
type PickerGroup struct {
|
||||
Label string
|
||||
Items []string
|
||||
}
|
||||
|
||||
// entry is a single row in the picker (either a group header or an item).
|
||||
type entry struct {
|
||||
label string
|
||||
isHeader bool
|
||||
selected bool
|
||||
groupIdx int
|
||||
flatIdx int // index across all items (ignoring headers), -1 for headers
|
||||
}
|
||||
|
||||
// Pick shows a full-screen grouped multi-select picker.
|
||||
// All items start deselected. Returns the flat indices of selected items
|
||||
// (0-based, spanning all groups in order). Returns nil if cancelled.
|
||||
// Returns a non-nil error if the terminal cannot be initialised, in which
|
||||
// case the caller should fall back to a simpler prompt.
|
||||
func Pick(groups []PickerGroup) ([]int, error) {
|
||||
screen, err := tcell.NewScreen()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := screen.Init(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer screen.Fini()
|
||||
|
||||
entries := buildEntries(groups)
|
||||
totalItems := countItems(entries)
|
||||
cursor := firstSelectableIndex(entries)
|
||||
offset := 0
|
||||
|
||||
for {
|
||||
w, h := screen.Size()
|
||||
selectedCount := countSelected(entries)
|
||||
|
||||
drawPicker(screen, entries, groups, cursor, offset, w, h, selectedCount, totalItems)
|
||||
screen.Show()
|
||||
|
||||
ev := screen.PollEvent()
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventResize:
|
||||
screen.Sync()
|
||||
case *tcell.EventKey:
|
||||
switch action := keyAction(ev); action {
|
||||
case actionQuit:
|
||||
return nil, nil
|
||||
case actionConfirm:
|
||||
if countSelected(entries) > 0 {
|
||||
return collectSelected(entries), nil
|
||||
}
|
||||
case actionUp:
|
||||
if cursor > 0 {
|
||||
cursor--
|
||||
}
|
||||
case actionDown:
|
||||
if cursor < len(entries)-1 {
|
||||
cursor++
|
||||
}
|
||||
case actionTop:
|
||||
cursor = 0
|
||||
case actionBottom:
|
||||
if len(entries) == 0 {
|
||||
cursor = 0
|
||||
} else {
|
||||
cursor = len(entries) - 1
|
||||
}
|
||||
case actionPageUp:
|
||||
listHeight := h - headerLines - footerLines
|
||||
cursor -= listHeight
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
case actionPageDown:
|
||||
listHeight := h - headerLines - footerLines
|
||||
cursor += listHeight
|
||||
if cursor >= len(entries) {
|
||||
cursor = len(entries) - 1
|
||||
}
|
||||
case actionToggle:
|
||||
toggleAtCursor(entries, cursor)
|
||||
case actionAll:
|
||||
setAll(entries, true)
|
||||
case actionNone:
|
||||
setAll(entries, false)
|
||||
}
|
||||
|
||||
// Keep the cursor visible
|
||||
listHeight := h - headerLines - footerLines
|
||||
if listHeight < 1 {
|
||||
listHeight = 1
|
||||
}
|
||||
if cursor < offset {
|
||||
offset = cursor
|
||||
}
|
||||
if cursor >= offset+listHeight {
|
||||
offset = cursor - listHeight + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- actions ----------------------------------------------------------------
|
||||
|
||||
type action int
|
||||
|
||||
const (
|
||||
actionNoop action = iota
|
||||
actionQuit
|
||||
actionConfirm
|
||||
actionUp
|
||||
actionDown
|
||||
actionTop
|
||||
actionBottom
|
||||
actionPageUp
|
||||
actionPageDown
|
||||
actionToggle
|
||||
actionAll
|
||||
actionNone
|
||||
)
|
||||
|
||||
func keyAction(ev *tcell.EventKey) action {
|
||||
switch ev.Key() {
|
||||
case tcell.KeyEscape, tcell.KeyCtrlC:
|
||||
return actionQuit
|
||||
case tcell.KeyEnter:
|
||||
return actionConfirm
|
||||
case tcell.KeyUp:
|
||||
return actionUp
|
||||
case tcell.KeyDown:
|
||||
return actionDown
|
||||
case tcell.KeyHome:
|
||||
return actionTop
|
||||
case tcell.KeyEnd:
|
||||
return actionBottom
|
||||
case tcell.KeyPgUp:
|
||||
return actionPageUp
|
||||
case tcell.KeyPgDn:
|
||||
return actionPageDown
|
||||
case tcell.KeyRune:
|
||||
switch ev.Rune() {
|
||||
case 'q':
|
||||
return actionQuit
|
||||
case ' ':
|
||||
return actionToggle
|
||||
case 'j':
|
||||
return actionDown
|
||||
case 'k':
|
||||
return actionUp
|
||||
case 'g':
|
||||
return actionTop
|
||||
case 'G':
|
||||
return actionBottom
|
||||
case 'a':
|
||||
return actionAll
|
||||
case 'n':
|
||||
return actionNone
|
||||
}
|
||||
}
|
||||
return actionNoop
|
||||
}
|
||||
|
||||
// --- data helpers ------------------------------------------------------------
|
||||
|
||||
func buildEntries(groups []PickerGroup) []entry {
|
||||
var entries []entry
|
||||
flat := 0
|
||||
for gi, g := range groups {
|
||||
entries = append(entries, entry{
|
||||
label: g.Label,
|
||||
isHeader: true,
|
||||
groupIdx: gi,
|
||||
flatIdx: -1,
|
||||
})
|
||||
for _, item := range g.Items {
|
||||
entries = append(entries, entry{
|
||||
label: item,
|
||||
isHeader: false,
|
||||
selected: false,
|
||||
groupIdx: gi,
|
||||
flatIdx: flat,
|
||||
})
|
||||
flat++
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func firstSelectableIndex(entries []entry) int {
|
||||
for i, e := range entries {
|
||||
if !e.isHeader {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func countItems(entries []entry) int {
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if !e.isHeader {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countSelected(entries []entry) int {
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if !e.isHeader && e.selected {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func collectSelected(entries []entry) []int {
|
||||
var result []int
|
||||
for _, e := range entries {
|
||||
if !e.isHeader && e.selected {
|
||||
result = append(result, e.flatIdx)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toggleAtCursor(entries []entry, cursor int) {
|
||||
if cursor < 0 || cursor >= len(entries) {
|
||||
return
|
||||
}
|
||||
e := entries[cursor]
|
||||
if e.isHeader {
|
||||
// Toggle entire group: if all selected -> deselect all, else select all
|
||||
allSelected := true
|
||||
for _, e2 := range entries {
|
||||
if !e2.isHeader && e2.groupIdx == e.groupIdx && !e2.selected {
|
||||
allSelected = false
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := range entries {
|
||||
if !entries[i].isHeader && entries[i].groupIdx == e.groupIdx {
|
||||
entries[i].selected = !allSelected
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entries[cursor].selected = !entries[cursor].selected
|
||||
}
|
||||
}
|
||||
|
||||
func setAll(entries []entry, selected bool) {
|
||||
for i := range entries {
|
||||
if !entries[i].isHeader {
|
||||
entries[i].selected = selected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- drawing ----------------------------------------------------------------
|
||||
|
||||
const (
|
||||
headerLines = 2 // title + blank line
|
||||
footerLines = 2 // blank line + keybinds
|
||||
)
|
||||
|
||||
var (
|
||||
styleDefault = tcell.StyleDefault
|
||||
styleTitle = tcell.StyleDefault.Bold(true)
|
||||
styleGroup = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorTeal)
|
||||
styleGroupCur = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorTeal).Reverse(true)
|
||||
styleCheck = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
|
||||
styleUncheck = tcell.StyleDefault.Dim(true)
|
||||
styleItem = tcell.StyleDefault
|
||||
styleItemCur = tcell.StyleDefault.Bold(true).Underline(true)
|
||||
styleCheckCur = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true).Underline(true)
|
||||
styleUncheckCur = tcell.StyleDefault.Dim(true).Underline(true)
|
||||
styleFooter = tcell.StyleDefault.Dim(true)
|
||||
)
|
||||
|
||||
func drawPicker(
|
||||
screen tcell.Screen,
|
||||
entries []entry,
|
||||
groups []PickerGroup,
|
||||
cursor, offset, w, h, selectedCount, totalItems int,
|
||||
) {
|
||||
screen.Clear()
|
||||
|
||||
// Title
|
||||
title := fmt.Sprintf(" Select traces to open (%d/%d selected)", selectedCount, totalItems)
|
||||
drawLine(screen, 0, 0, w, title, styleTitle)
|
||||
|
||||
// List area
|
||||
listHeight := h - headerLines - footerLines
|
||||
if listHeight < 1 {
|
||||
listHeight = 1
|
||||
}
|
||||
|
||||
for i := 0; i < listHeight; i++ {
|
||||
ei := offset + i
|
||||
if ei >= len(entries) {
|
||||
break
|
||||
}
|
||||
y := headerLines + i
|
||||
renderEntry(screen, entries, groups, ei, cursor, w, y)
|
||||
}
|
||||
|
||||
// Scrollbar hint
|
||||
if len(entries) > listHeight {
|
||||
drawScrollbar(screen, w-1, headerLines, listHeight, offset, len(entries))
|
||||
}
|
||||
|
||||
// Footer
|
||||
footerY := h - 1
|
||||
footer := " ↑/↓ move space toggle a all n none enter open q/esc quit"
|
||||
drawLine(screen, 0, footerY, w, footer, styleFooter)
|
||||
}
|
||||
|
||||
func renderEntry(screen tcell.Screen, entries []entry, groups []PickerGroup, ei, cursor, w, y int) {
|
||||
e := entries[ei]
|
||||
isCursor := ei == cursor
|
||||
|
||||
if e.isHeader {
|
||||
groupSelected := 0
|
||||
groupTotal := 0
|
||||
for _, e2 := range entries {
|
||||
if !e2.isHeader && e2.groupIdx == e.groupIdx {
|
||||
groupTotal++
|
||||
if e2.selected {
|
||||
groupSelected++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label := fmt.Sprintf(" %s (%d/%d)", e.label, groupSelected, groupTotal)
|
||||
style := styleGroup
|
||||
if isCursor {
|
||||
style = styleGroupCur
|
||||
}
|
||||
drawLine(screen, 0, y, w, label, style)
|
||||
return
|
||||
}
|
||||
|
||||
// Item row: " [x] label" or " > [x] label"
|
||||
prefix := " "
|
||||
if isCursor {
|
||||
prefix = " > "
|
||||
}
|
||||
|
||||
check := "[ ]"
|
||||
cStyle := styleUncheck
|
||||
iStyle := styleItem
|
||||
if isCursor {
|
||||
cStyle = styleUncheckCur
|
||||
iStyle = styleItemCur
|
||||
}
|
||||
if e.selected {
|
||||
check = "[x]"
|
||||
cStyle = styleCheck
|
||||
if isCursor {
|
||||
cStyle = styleCheckCur
|
||||
}
|
||||
}
|
||||
|
||||
x := drawStr(screen, 0, y, w, prefix, iStyle)
|
||||
x = drawStr(screen, x, y, w, check, cStyle)
|
||||
drawStr(screen, x, y, w, " "+e.label, iStyle)
|
||||
}
|
||||
|
||||
func drawScrollbar(screen tcell.Screen, x, top, height, offset, total int) {
|
||||
if total <= height || height < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
thumbSize := max(1, height*height/total)
|
||||
thumbPos := top + offset*height/total
|
||||
|
||||
for y := top; y < top+height; y++ {
|
||||
ch := '│'
|
||||
style := styleDefault.Dim(true)
|
||||
if y >= thumbPos && y < thumbPos+thumbSize {
|
||||
ch = '┃'
|
||||
style = styleDefault
|
||||
}
|
||||
screen.SetContent(x, y, ch, nil, style)
|
||||
}
|
||||
}
|
||||
|
||||
// drawLine fills an entire row starting at x=startX, padding to width w.
|
||||
func drawLine(screen tcell.Screen, startX, y, w int, s string, style tcell.Style) {
|
||||
x := drawStr(screen, startX, y, w, s, style)
|
||||
// Clear the rest of the line
|
||||
for ; x < w; x++ {
|
||||
screen.SetContent(x, y, ' ', nil, style)
|
||||
}
|
||||
}
|
||||
|
||||
// drawStr writes a string at (x, y) up to maxX and returns the next x position.
|
||||
func drawStr(screen tcell.Screen, x, y, maxX int, s string, style tcell.Style) int {
|
||||
for _, ch := range s {
|
||||
if x >= maxX {
|
||||
break
|
||||
}
|
||||
screen.SetContent(x, y, ch, nil, style)
|
||||
x++
|
||||
}
|
||||
return x
|
||||
}
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -10701,7 +10701,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user