Compare commits

..

3 commits

Author SHA1 Message Date
Leon Mika b8ec434b5d Added tests and fixed other unit tests
All checks were successful
ci / build (pull_request) Successful in 3m48s
2025-10-24 17:26:38 +11:00
Leon Mika a1bda94e74 Fixed docker 2025-10-24 17:23:38 +11:00
Leon Mika 4e41ae9cd2 Added rs:first 2025-10-24 08:13:18 +02:00
95 changed files with 238 additions and 3024 deletions

View file

@ -4,10 +4,12 @@ on:
push:
branches:
- main
- feature/*
pull_request:
branches:
- main
jobs:
Build:
build:
runs-on: docker
services:
localstack:
@ -22,7 +24,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.25
go-version: 1.24
- name: Configure
run: |
git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika"
@ -33,4 +35,4 @@ jobs:
go test -p 1 ./...
env:
TEST_DYNAMO_URL: "http://localstack:4566"
GOPRIVATE: "github:com/lmika/*"
GOPRIVATE: "github:com/lmika/*"

View file

@ -1,12 +1,11 @@
name: Release
name: release
on:
push:
tags:
- 'v*'
jobs:
Build:
build:
runs-on: docker
services:
localstack:
@ -21,7 +20,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.25
go-version: 1.24
- name: Configure
run: |
git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika"
@ -31,71 +30,53 @@ jobs:
go get ./...
go test -p 1 ./...
env:
TEST_DYNAMO_URL: "http://localstack:4566"
GOPRIVATE: "github:com/lmika/*"
TEST_DYNAMO_URL: "http://localstack:4566"
Site:
needs: Build
runs-on: docker
env:
NETLIFY_SITE_ID: 987651c8-4ffd-48d8-af67-4dbd49c48887
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
release-macos:
needs: build
runs-on: macos-12
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.24
- uses: actions/setup-node@v4
with:
node-version: 21.1
- name: Install Hugo
run: |
curl -LO https://github.com/gohugoio/hugo/releases/download/v0.146.0/hugo_extended_0.146.0_linux-amd64.deb
apt install -y ./hugo_extended_0.146.0_linux-amd64.deb
- name: Install Netlify CLI
run: |
npm install netlify-cli@15.0.1 -g
- name: Build Site
run: |
cd _site
mkdir -p themes
git clone https://github.com/alex-shpak/hugo-book.git themes/hugo-book
npm install
hugo --minify
- name: Publish Site
run: |
cd _site
netlify deploy --dir docs --prod
'Release':
needs: Build
runs-on: macos
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.25
- name: Setup Dependencies
run: |
brew install gpg
go-version: 1.22
- name: Configure
run: |
git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika"
- name: Setup Goreleaser
run: |
go install github.com/goreleaser/goreleaser/v2@v2.12.7
brew install goreleaser/tap/goreleaser
brew install goreleaser
- name: Release
if: startsWith(github.ref, 'refs/tags/')
run: |
goreleaser release -f goreleaser.yml --skip=validate --clean
goreleaser release -f macos.goreleaser.yml --skip=validate --clean
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_PRIVATE_KEY: ${{ secrets.HOMEBREW_TAP_PRIVATE_KEY }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
release-linux:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.22
- name: Configure
run: |
git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika"
- name: Release
uses: goreleaser/goreleaser-action@v1
if: startsWith(github.ref, 'refs/tags/')
with:
version: latest
args: release -f linux.goreleaser.yml --skip=validate --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}

3
.gitignore vendored
View file

@ -1,6 +1,3 @@
debug.log
.DS_store
.idea
# Local Netlify folder
.netlify

6
_certs/.gitignore vendored
View file

@ -1,6 +0,0 @@
*.key
*.p8
*.certSigningRequest
*.cer
*.p12
*.txt

View file

@ -1,50 +0,0 @@
# Certs
These hold the certificates for MacOS notarisation. As such they are not checked into the repository.
List of files is as follows:
- ALDsigning.key : private key
- csr3072ALDSigning.certSigningRequest : certificate signing request
- developerID_application.p12 : signed certificate
- keyStore.p12 : pkcs12 keystore holding both the certificate and private key
- AthKey_UD4...p8 : private key granting API access to AppStore connect
## Producing These Files
To produce the keys, run the following command:
```bash
# create the private key. It must be RSA 2048
$ openssl genrsa -out ALDsigning.key 2048
# create the CSR
$ openssl req -new -key ALDsigning.key -out csr3072ALDSigning.certSigningRequest -subj "/emailAddress=lmika@lmika.org, CN=dev.lmika.dynamo-browse, C=IE"
```
These are based on [these instructions](https://developer.apple.com/help/account/certificates/create-a-certificate-signing-request).
The instructions are incorrect though. They claim that the key lenght should be 3096, but AppStore connect only supports 2048.
Then, upload the CSR to AppStore Connect, choosing the "Developer ID Application" certificate type. If successful,
you will be given a signed certificate, which will have the filename `developerID_application.signing.cer`.
Then, produce a PKCS12 (.p12) file by running the following command ([source](https://stackoverflow.com/questions/21141215/creating-a-p12-file)):
```bash
openssl pkcs12 -export -out keyStore.p12 -inkey ALDsigning.key -in developerID_application.signing.cer
```
## Getting the .p8 file
To download the .p8 file, go to the [Apple Developer Portal](https://appstoreconnect.apple.com/access/integrations/api/new),
and download a new API key for AppStore Connect. The role of the new key should be "Developer".
## Configuring the CI/CD secrets
The following secrets correspond to the given secrets:
- `MACOS_SIGN_P12`: base64 of keyStore.p12
- `MACOS_SIGN_PASSWORD` the p12 password
- `MACOS_NOTARY_ISSUER_ID`: see the UUID on this page: https://appstoreconnect.apple.com/access/integrations/api
- `MACOS_NOTARY_KEY_ID`: the ID of the .p8 file - `U4....`
- `MACOS_NOTARY_KEY`: base64 of the .p8 file

4
_site/.gitignore vendored
View file

@ -1,4 +0,0 @@
docs/
node_modules/
resources/
themes/

View file

View file

@ -1,6 +0,0 @@
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
---

View file

@ -1,55 +0,0 @@
:root {
--accent: #6591ff;
}
figure.screenshot {
text-align: center;
}
div.site-header {
text-align: center;
margin-bottom: 48px;
display: flex;
flex-direction: row;
align-content: center;
justify-content: center;
align-items: center;
& img {
width: 64px;
height: 64px;
}
& h1 {
font-size: 2.2em;
font-variant: small-caps;
vertical-align: middle;
margin: 0;
margin-left: 16px;
}
}
kbd {
background: var(--body-font-color);
color: var(--body-background);
border-radius: 4px;
font-weight: bold;
padding: 2px 3px;
font-size: 1.0em;
}
input {
-webkit-appearance: auto;
appearance: auto;
}
/**
* Keybinding settings.
*/
table.key-bindings.show-binding-names .kb-key-binding {
display: none;
}
table.key-bindings:not(.show-binding-names) .kb-binding-name {
display: none;
}

View file

@ -1,17 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export class KeybindingsController extends Controller {
static targets = [
"showBindingNames",
"keyBindingTable"
];
bindingNamesChanged() {
let showBindingNames = this.showBindingNamesTarget;
if (showBindingNames.checked) {
this.keyBindingTableTarget.classList.add("show-binding-names");
} else {
this.keyBindingTableTarget.classList.remove("show-binding-names");
}
}
}

View file

@ -1,5 +0,0 @@
import { Application } from "@hotwired/stimulus";
import { KeybindingsController } from "./controllers/keybindings_controller";
const application = Application.start();
application.register("keybindings", KeybindingsController);

View file

@ -1,10 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": [
"*"
]
}
}
}

View file

@ -1,19 +0,0 @@
#!/usr/bin/env python3
import os.path
import subprocess
images = [
'main-item-view.png',
'table-selection.png',
'filter-items.png',
'query-items.png',
'modified-items.png',
'fields-popup.png'
]
for image in images:
src_file = os.path.join('images', image)
target_file = os.path.join('static/images/dynamo-browse', image)
subprocess.run(['magick', src_file, '-shave', '24x16', target_file])

View file

@ -1,34 +0,0 @@
baseURL = "https://dynamobrowse.app/"
languageCode = "en-us"
title = "Dynamo-Browse"
theme = "hugo-book"
publishDir = "docs"
[markup.goldmark.renderer]
unsafe = true
[params]
BookTheme = "auto"
BookMenuBundle = "/menu"
[params.Logo]
logoText = "Dynamo-Browse"
[menu]
[[menu.main]]
identifier = "download"
name = "Download"
url = "/download"
weight = 20
[[menu.main]]
identifier = "docs"
name = "Documentation"
url = "/docs"
weight = 40
[[menu.main]]
identifier = "updates"
name = "Updates"
url = "/updates"
weight = 50

View file

@ -1,46 +0,0 @@
---
weight: 10
---
<div class="site-header">
<img src="/images/dynamo-browse/dynamo-browse-logo.png">
<h1>Dynamo-Browse</h1>
</div>
<figure class="screenshot">
<img src="/images/dynamo-browse/main-item-view.png" alt="dynamo-browse">
</figure>
Dynamo-Browse is a terminal-based UI (TUI) app for working with DynamoDB tables.
With it, you can quickly connect to and browse the contents of a DynamoDB table
in your AWS account or local machine. There are some basic facilities for
editing as well.
## Getting Started
This video gives a brief introduction of how to use Dynamo-Browse to view the items of a DynamoDB table:
{{< youtube cQnTIg1_tfg >}}
More information about the tool can be found within the [Dynamo-Browse user manual](/docs).
## Download
Binary packages for MacOS and Linux can be [found at Forgejo](https://lmika.dev/cmd/dynamo-browse/releases).
### MacOS Using Homebrew
If you have Homebrew, you can install Dynamo-Browse using the following command:
```
brew tap lmika/dynamo-browse https://lmika.dev/casks/dynamo-browse
brew install dynamo-browse
```
### Go
If you have Go installed, you can install Dynamo-Browse using the following command:
```
go install lmika.dev/cmd/dynamo-browse/cmd/dynamo-browse@latest
```

View file

@ -1,4 +0,0 @@
---
bookFlatSection: true
weight: 40
---

View file

@ -1,157 +0,0 @@
---
title: Commands
weight: 20
---
# Commands
These commands are meant for general, interactive use. For additional commands meant for defining
extensions, see [Extensions](/docs/reference/extensions/).
## clone
```
:clone
```
Copies the currently selected item to a new item, which will appear at the bottom of the table.
Cloning an item will prompt for a new partition key and sort key but will not check for duplicates.
## del-attr
```
:del-attr <attribute>
```
Alias: `da`
Deletes _attribute_ from the currently selected item; or if there are any marked items, the marked items.
## delete
```
:delete
```
Deletes the marked items. Unlike the other commands that modify items, this command will be executed on
the table straight away.
## echo
```
:echo [message ...]
```
Displays _message_ in the status bar. Mainly used for debugging.
## export
```
:export <filename> [-all]
```
Writes the currently loaded items as a CSV file to _filename_.
Only string, numerical, and boolean values will be written to the export; all other value types will be
black. Exporting will honour the columns currently visible in the table. Filtered items will also be included
in the exported file.
When called with the `-all` flag, any subsequent pages will be included in the export. If invoked after running
a query, all items returned from that query will be exported to file.
## mark
```
:mark [all | none | toggle] [-where <expr>]
```
Mark the rows in the following way:
- `all`: will mark all rows. This is the default when invoked without an argument.
- `none`: will unmark all rows.
- `toggle`: will toggle all marked and unmarked rows.
Adding the `-where` option would only select rows that match the given query expression.
## new-item
```
:new-item
```
Creates a new item. When executed, the value for the partition key and sort key will be prompted.
The new item will not be written to the table until it is committed with the `put` command.
## put
```
:put
```
Alias: `w`
Commits all new and modified items to the table.
## quit
```
:quit
```
Alias: `q`
Quits Dynamo-Browse.
## rebind
```
:rebind <bindingName> <key>
```
Rebinds the action with _bindingName_ to _key_. This will replace any existing binding for that action.
See [Key Bindings](#key-bindings) with "Show binding names" checked to see available binding names.
## set-opt
```
:set <name> [value]
```
Set the value of a setting. Flag setting types can be enabled without any value. See [Settings](#settings) for possible setting values.
## set-attr
```
:set-attr <attributeName> [type]
```
Alias: `sa`
Modifies the value of _attribute_ of the currently selected item; or if there are any marked items, the marked items.
The value of _type_ can be use to specify the type of the attribute. It can be one of the following (case insensitive):
- `-S`: string value
- `-N`: number value
- `-BOOL`: boolean value
- `-NULL`: null value
- `-TO`: value of an expression
If unset, the attribute type will not be changed. _type_ must be set if multiple items have been marked.
## table
```
:table
```
Select the table to display.
## unmark
```
:unmark
```
Unmark all marked items. This is essentially an alias for `mark none`.

View file

@ -1,7 +0,0 @@
---
title: "Extensions"
type: script-api
weight: 60
draft: true
---
# Extensions

View file

@ -1,193 +0,0 @@
---
title: Key Bindings
weight: 10
---
# Key Bindings
<div data-controller="keybindings">
<label>
<input type="checkbox" id="show-kb-binding-names"
data-keybindings-target="showBindingNames" data-action="keybindings#bindingNamesChanged"> Show binding names
</label>
<table class="key-bindings" data-keybindings-target="keyBindingTable">
<thead>
<tr>
<th class="kb-key-binding" style="text-align:left">Key</th>
<th class="kb-binding-name" style="text-align:left">Binding Name</th>
<th style="text-align:left">Action</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2">Main View Mode</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&uarr;</kbd>/<kbd>i</kbd></td>
<td class="kb-binding-name">table.move-up</td>
<td>Move selection up</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&darr;</kbd>/<kbd>k</kbd></td>
<td class="kb-binding-name">table.move-down</td>
<td>Move selection down</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>PgUp</kbd>/<kbd>&#8679;I</kbd></td>
<td class="kb-binding-name">table.page-up</td>
<td>Page up</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>PgDn</kbd>/<kbd>&#8679;K</kbd></td>
<td class="kb-binding-name">table.page-down</td>
<td>Page down</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>Home</kbd>/<kbd>0</kbd></td>
<td class="kb-binding-name">table.goto-top</td>
<td>Move selection to first item</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>End</kbd>/<kbd>$</kbd></td>
<td class="kb-binding-name">table.goto-bottom</td>
<td>Move selection to last item</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&larr;</kbd>/<kbd>j</kbd></td>
<td class="kb-binding-name">table.move-left</td>
<td>Scroll displayed columns left</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&rarr;</kbd>/<kbd>l</kbd></td>
<td class="kb-binding-name">table.move-right</td>
<td>Scroll displayed columns right</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>Backspace</kbd></td>
<td class="kb-binding-name">view.view-back</td>
<td>Go back</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>\</kbd></td>
<td class="kb-binding-name">view.view-forward</td>
<td>Go forward</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>w</kbd></td>
<td class="kb-binding-name">view.cycle-layout-forward</td>
<td>Cycle forward through layout</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&#8679;W</kbd></td>
<td class="kb-binding-name">view.cycle-layout-backwards</td>
<td>Cycle backwards through layout</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>m</kbd></td>
<td class="kb-binding-name">view.mark</td>
<td>Mark/unmark currently selected item</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>M</kbd></td>
<td class="kb-binding-name">view.toggle-marked-items</td>
<td>Toggle marked/unmarked items</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>c</kbd></td>
<td class="kb-binding-name">view.copy-item-to-clipboard</td>
<td>Copy displayed item to pasteboard</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>C</kbd></td>
<td class="kb-binding-name">view.copy-table-to-clipboard</td>
<td>Copy displayed table to pasteboard as a CSV</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>/</kbd></td>
<td class="kb-binding-name">view.prompt-for-filter</td>
<td>Filter</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>?</kbd></td>
<td class="kb-binding-name">view.prompt-for-query</td>
<td>Run scan/query</td>
</tr>
<tr class="kb-binding-name">
<td class="kb-key-binding"></td>
<td class="kb-binding-name">view.prompt-for-table</td>
<td>Select table</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>R</kbd></td>
<td class="kb-binding-name">view.rescan</td>
<td>Rerun last scan/query</td>
</tr>
<tr class="kb-binding-name">
<td class="kb-key-binding"><kbd>&gt;</kbd></td>
<td class="kb-binding-name">view.fetch-next-page</td>
<td>Fetch the next page of results</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>:</kbd></td>
<td class="kb-binding-name">view.prompt-for-command</td>
<td>Enter command</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>f</td>
<td class="kb-binding-name">view.show-fields-popup</td>
<td>Show fields popup</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>^C</kbd></td>
<td class="kb-binding-name">view.cancel-running-job</td>
<td>Cancel running operation</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>^C</kbd>/<kbd>Esc</kbd></td>
<td class="kb-binding-name">view.quit</td>
<td>Quit</td>
</tr>
<tr>
<td colspan="2">Field Popup Mode</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&#8679;I</kbd></td>
<td class="kb-binding-name">fields-popup.shift-column-left</td>
<td>Shift selected column left</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&#8679;K</kbd></td>
<td class="kb-binding-name">fields-popup.shift-column-right</td>
<td>Shift selected column right</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>Space</kbd></td>
<td class="kb-binding-name">fields-popup.toggle-column-visible</td>
<td>Toggle selected column visible</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>a</kbd></td>
<td class="kb-binding-name">fields-popup.add-column</td>
<td>Add new column</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>d</kbd></td>
<td class="kb-binding-name">fields-popup.delete-column</td>
<td>Delete selected column</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>&#8679;R</kbd></td>
<td class="kb-binding-name">fields-popup.reset-columns</td>
<td>Reset columns to that of the result-set</td>
</tr>
<tr>
<td class="kb-key-binding"><kbd>^C</kbd>/<kbd>Esc</kbd></td>
<td class="kb-binding-name">fields-popup.close</td>
<td>Close field popup</td>
</tr>
</tbody>
</table>
</div>

View file

@ -1,53 +0,0 @@
---
title: Launch Flags
weight: 40
---
# Launch Flags
## -debug
```
-debug <filename>
```
Enable debug logs, which will be written to _filename_.
## -default-limit
```
-default-limit <int>
```
Sets the default limit of queries or scans. The default is 1,000 items.
## -local
```
-local [host]:<port>
```
Connect to a local DynamoDB service listening on _host_:_port_. The default _host_ is `localhost`.
## -ro
```
-ro
```
Enable read-only mode.
## -t
```
-t <tableName>
```
Open the table _tableName_, instead of prompting for a table.
## -w
```
-w <workspaceFile>
```
Use _workspaceFile_ as the workspace file. If unset, a temporary file will be used for the workspace.

View file

@ -1,255 +0,0 @@
---
title: Query Expressions
weight: 30
---
# Query Expression
Query expressions are used to select rows of a table. When executed as a query (i.e. by pressing <kbd>?</kbd>),
they will be translated into query or table scans that will run over the DynamoDB table in AWS.
They work similar to the "where" clause in PartiQL except that they only require Query and Scan permission
on the AWS table and do not require "select" clauses.
Such expressions can also be used in other areas of Dynamo-Browse, such as populating the value of new columns.
## Names And Values
A query expressions support the following literals:
- Strings: `"Hello"`
- Integers: `123`
- Boolean: `true` or `false`
Field names are represented as regular identifiers, such as `pk` or `address`.
## Equality
To select rows with a field that equals a given value, use the `=` operator:
```
pk = "something"
```
Either operand will can be an identifier, placeholder, or value that resolves to any type.
The result will be true if both the LHS and RHS equal the same type and value. If the types differ or
the values differ, the result will be false. The field types can be different, but will always produce false.
The compliment is the `!=` operator:
```
pk != "not this"
```
## Numerical Comparison
The operands `<`, `<=`, `>`, `>=` can be used to compare numerical fields and values:
```
three < 5 // true
three <= 3 // true
three > 12 // false
three >= 1 // true
```
To verify that a number exists within a range, use the `between` operand:
```
three between 1 and 5 // true
```
## Prefix Operator
To select rows with a field that starts with a given substring, use the `^=` operator:
```
pk ^= "some"
```
This is equivalent to using the [begins_with](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html) function in AWS query expressions.
## Logical Operators
The logical operators `and`, `or` and `not` can be used to express conjunctions, disjunctions and logical negation
between multiple expressions:
```
pk = "this" and sk = "that"
pk != "that" and count > 123
not count = 21
```
The order of precedence of these operators, from lowest to highest, is `or`, `and`, then `not`. This differs
from AWS, in which all operators have the same precedence. For example, the query:
```
pk="this" or pk="that" and sk="foo"
```
is equivalent to:
```
pk="this" or (pk="that" and sk="foo")
```
The order can be overridden using brackets:
```
(pk="this" or pk="that") and sk="foo"
```
{{< hint info >}}
If a query expression is of the form `pk = <val>` or `pk = <val> and sk <op> <val>`,
where:
- _pk_ and _sk_ are the partition and sort keys of the base table or a GSI,
- _val_ resolves to a constant value, and,
- _op_ is either `=`, `^=`, `<`, `<=`, `>`, `>=`, or `between`
the expression will be executed as a Query call. Unlike expressions on the AWS Query API method itself,
the order of the `pk` and `sk` subexpressions can be swapped.
Other expressions are supported but they will be executed as a table Scan.
{{< /hint >}}
## The `in` Operator
The `in` operator can be used to determine if a value exists in a collection:
```
three in (1, 2, 3, 4, 5)
name in ("Tom", "Dick", "Harry")
```
The result will be a boolean, which will be true if the value of the LHS equals any of the items within the RHS.
The collection can be one or more fixed set of values within parenthesis separated by commas. A single
value present within parenthesis is equivalent to the equality test:
```
three in (3) // equivalent to: three = 3
```
The right hand side can also be a subexpression without parenthesis that will resolve to either a string,
list or map. The operand will behave differently based on the RHS type:
- If the RHS is a string, the result will be true if the LHS is a substring of the RHS (equivalent to the `contains` AWS conditional expressions function)
- If the RHS is a list, the result will be true if the LHS equals any of the items of the list
- If the RHS is a map, the result will be true if the LHS appears as a key of the map
The compliment operand is `not in`:
```
three not in (6, 7, 8, 9)
```
## The `is` Operator
The `is` operator can be used to assert the value type. The RHS operand is a string which is to represent an AWS
DynamoDB item attribute type, for example `S` for strings, `N` for numbers, etc.
```
"hello" is "S" // true
123 is "N" // true
"hello" is "N" // false
```
This is equivalent to the `attribute_type` AWS conditional expressions function.
The special value `any` can be used to check that a field is set, regardless of type:
```
pk is "any" // true
```
This is equivalent to the `attribute_exists` AWS conditional expressions function.
The compliment operand is `not is`. Using it with the "any" special value (`not is "any"`) is equivalent to the
`attribute_not_exists` AWS conditional expressions function.
## The `using` Options
A query that is to be executed on the actual table in AWS will go though a short planning phase to determine
whether it's possible to invoke the expression as a `Query` call. If the attributes map to partition and sort keys
of either the main table, or exactly one GSI associated with the table, the expression will be executed as a Query
over the table or the GSI found with those attributes.
In cases where multiple GSI candidates exist on the base table, the query will fail with the following error:
```
multiple plans with index found. Specify index or scan with 'using' clause
```
In these cases, the index will need to be specified with the `using` keyword with the `index` option:
```
address="something" using index("specific-gsi-name")
```
The `using` keyword can also be used to force the expression to run as a table scan,
even if the query can be invoked using a Query call over the base table or GSI:
```
address="something" using scan
```
## Builtin Functions
Query expressions support a number of builtin functions.
### The `marked` function
```
marked(fieldname)
```
The `marked` function will return a list of field values of all marked rows of the current result set. The
items will appear in the list as they appear in the result set. The _fieldname_ currently only supports top-level fields.
If no fields are marked, the empty list is returned.
```
marked("city")
```
### The `range` function
```
range(from, to)
```
The `range` function will return a list of integers between _from_ and _to_ inclusive. Non integers will be truncated
to integers, and the step is always be 1.
```
range(2, 5) // [2, 3, 4, 5]
three in range(2, 5) // true
```
### The `size` function
```
size(v)
```
The `size` function will return the number of items of a list or map, or the length of a string.
{{< hint info >}}
The `size` function is equivalent to the `size` AWS conditional expressions function, and as such is the
only function that is included as is in the generated Query or Scan expression. All other functions are evaluated
prior to making the Query or Scan AWS call.
{{</hint>}}
## Placeholders
In some circumstances, such as the [session.query](/docs/reference/script-api/#session-query) method, it's possible to use a placeholder as a field or value. To expand a placeholder to an identifier, use the `:` prefix. To expanded the placeholder as a value, use the `$` prefix. For example, the expression `:key = $value` in the following script:
```
out := session.query(":key = $value", {
table: "some-table",
args: {
key: "pk",
value: "value"
}
}
```
Is equivalent to the query `pk = "hello"`, as the placeholder `:key` is expanded to an identifier and `$value` is expanded
to a value, in this case a string.

View file

@ -1,25 +0,0 @@
---
title: Settings
weight: 50
---
# Settings
## default-limit
- Type: int
- Default: `1000`
The maximum number of rows returned from a query or scan.
## ro
- Type: flag
Enable read-only mode. When enabled, all modification operations are disabled, and will fail with a `Read-only mode` error.
The `rw` setting will disable read-only mode.
## rw
- Type: flag
Disable read-only mode. The `ro` setting will enable read-only mode.

View file

@ -1,4 +0,0 @@
---
bookFlatSection: true
weight: 30
---

View file

@ -1,50 +0,0 @@
---
title: Customising Dynamo-Browse
weight: 50
---
# Customising Dynamo-Browse
Some commands can be used to customise Dynamo-Browse, such as modify key bindings.
The effect of these commands will only be applied for the duration of the session: they are currently not
tracked within the workspace file. To keep customisations across relaunches, these commands
can be added to an RC file.
## RC Files
RC files are text files containing commands that will be executed by Dynamo-Browse upon launch.
By default, RC files are located in `$HOME/.config/dynamo-browse/` and have the suffix `.ucl`. For example:
```
$HOME/.config/dynamo-browse/init.ucl
```
Any number of RC files can be present in this directory, and they are executed in lexicographical order.
## Rebinding Keys
The default key bindings of Dynamo-Browse can be changed using the [rebind](/docs/reference/commands#rebind) command. This takes
a binding name corresponding to the particular action to invoke, and the key
to which it should be mapped to.
Putting these commands in the RC file will effectively change the default bindings of Dynamo-Browse.
```
# Rebind T to prompt for a table
rebind "view.prompt-for-table" "T"
# Rebind escape to prompt for a command
rebind "view.prompt-for-command" "esc"
```
At the moment each binding name can only be mapped to a single key. It's also currently not possible
to setup bindings for commands. These may be supported in the future.
A list of available binding names can be found the the [reference](/docs/reference/key-bindings)
(check the "Show binding names" checkbox). Note that some bindings may not have default key bindings.
## Extensions
The RC files are primarily intended for commands that customise Dynamo-Browse in a particular way, but any
command can be entered here, including those for defining new commands or running predefined queries. These
are generally known as extensions, although act as any other RC file located within that directory. More
information on extensions can be found in the [reference](/docs/reference/script-api).

View file

@ -1,95 +0,0 @@
---
title: Editing Items
weight: 40
---
# Editing Items
Dynamo-Browse offers some basic facilities for editing items — such as creating items, deleting items,
and modifying their attribute values.
<figure class="screenshot">
<img src="/images/dynamo-browse/modified-items.png" alt="Item indicators">
</figure>
## Marking Items
Most modifications are applied to items that are marked. A marked item is indicated by a grey
background and a bullet indicator (`•`) on the left side of the table.
To mark or unmark the selected item, press <kbd>m</kbd>.
The command `unmark` can be used to clear all marked items.
## Modifying Attributes
Item attributes can be added or modified by using the command `set-attr` or the alias `sa`.
This command can be used to modify the value and type of an attribute of the currently selected items, or
from any marked items.
The format of the command is as follows:
```
:set-attr [<type>] <attributeName>
```
Where type is one of the following (case insensitive):
- `-S`: string
- `-N`: number
- `-BOOL`: boolean
- `-NULL`: null
If the type is not specified, and the attribute exists, then the attribute type will not change.
The type must be specified if this is a new attribute or multiple items have been marked.
After executing the command, Dynamo-Browse will prompt for the value of the new attribute if one is
required.
Modified attributes will only be tracked in memory: they will not be written
to the actual table until it is "putted" (see [Committing Changes](#committing-changes)).
An item that has been modified will be displayed in red and a modified indicator (`M`) will appear
on the left-most column.
## Deleting Attributes
An attribute can be deleted by using the command `del-attr` or the alias `da`. The format of the command
is as follows:
```
:del-attr <attributeName>
```
When executed, the attribute with the name _attributeName_ will be deleted from the selected item, or
from any marked items.
Deleted attributes will only be tracked in memory: they will not be removed from
the actual table until it is "putted" (see [Committing Changes](#committing-changes)).
An item that has been modified will be displayed in red and a modified indicator (`M`) will appear
on the left-most column.
## Adding Items
A new item can be created by typing in the command `new-item`.
When entered, Dynamo-Browse will prompt for the partition and sort key. Once these are entered,
the item will appear in the top pane in green with an asterisk indicator (`*`) on the left left-most column.
Any additional attributes can be set by using `set-attr`.
A new item will only appear in memory: it will not be written
to the actual table until it is "putted" (see [Committing Changes](#committing-changes)).
## Deleting Items
Items can be deleted by marking them and then typing in the command `delete`.
Unlike most of the other modified commands, running `delete` WILL make changes to the table
immediately.
## Committing Changes
New or modified items (but not deleted items) will be kept in memory until they are committed
or "putted" to the table. To put the changes, use the `put` command or `w` alias.
## Backing Out of Changes
Any modified items can be reverted back to what they are in the actual table by rerunning the
current query. This can be done by pressing <kbd>&#8679;R</kbd>.

View file

@ -1,60 +0,0 @@
---
title: Querying and Filtering Results
weight: 30
---
# Querying and Filtering Results
## Querying
<figure class="screenshot">
<img src="/images/dynamo-browse/query-items.png" alt="Items with query applied">
</figure>
A query or scan over the table can be performed by entering a _Query Expression_.
Query expressions are a built-in expression language which translates to either a DynamoDB query
or scan, depending on the expression.
Query expressions are entered as expressions, with most being of the form `attribute operator value`.
For example, the expression for selecting records where `color` equals `red` is:
```
color = "red"
```
The `color` attribute can either be a partition key, sort key, or a regular attribute. Dynamo Browse
will do it's best to run the query as a DynamoDB query if possible. Usually query expressions of the
form `pk = <something>` or `pk = <something> and sk <comparison> <something>` where `pk` is a partition key,
`sk` is the sort key, and `<comparison>` is one of the [equality](/docs/reference/query-expressions/#equality),
[numerical comparison](/docs/reference/query-expressions/#numerical-comparison), or
[prefix operator](/docs/reference/query-expressions/#prefix-operator), will be executed as queries.
Other expressions will be executed as scans.
Details about the Query Expression language can be found in the
[Query Expressions references](/docs/reference/query-expressions/).
To run a query, press <kbd>?</kbd>, and enter the query expression.
To clear a query, press <kbd>?</kbd>, and press <kbd>Enter</kbd> without entering any value.
While the query is running, a spinner indicating activity will be shown in the status bar. A running
query can be cancelled while this spinner is visible by pressing <kbd>^C</kbd>. You have the option
to view any partial results that have been retrieved at the time.
## Filtering
<figure class="screenshot">
<img src="/images/dynamo-browse/filter-items.png" alt="Items with filter applied">
</figure>
The displayed items of the current result-set can be filtered down to those that contain a specific substring.
To set the filter, press <kbd>/</kbd>, and enter the substring you wish to filter on.
To clear the filter, press <kbd>/</kbd>, and press <kbd>Enter</kbd> without entering any value.
When a filter is set, any item that does not have a top-level attribute containing the substring will be hidden.
Filtering will only consist the items that are in the current result-set. It will not result in a call to the actual
table itself.
Note that filtering is case sensitive.

View file

@ -1,157 +0,0 @@
---
title: Getting Around
weight: 20
---
# Getting Around
After selecting a table, Dynamo-Browse will perform a scan and present the results in the default view mode.
<figure class="screenshot">
<img src="/images/dynamo-browse/main-item-view.png" alt="Main item view">
</figure>
This mode consists of three panes:
- The top pane displays the result-set of the last scan or query. The table name is at the top-left.
- The middle pane displays the attributes of the currently selected item, along with their type.
- The bottom pane displays the current query or filter, plus any messages. Prompts for input will
also appear at the bottom.
The result-set is sorted in ascending order based on the value and type of the partition and sort key.
Up to 1,000 rows will be displayed for the current result-set.
Since DynamoDB does not require all items to have the same attribute (unless they are pre-defined), any
attribute not set for a column is indicated with a grey tilde character `~`.
Use the following keys to change the currently selected row, which is highlighted in purple:
- <kbd>&uarr;</kbd>/<kbd>i</kbd>: Move selection up
- <kbd>&darr;</kbd>/<kbd>k</kbd>: Move selection down
- <kbd>PgUp</kbd>/<kbd>&#8679;I</kbd>: Page up
- <kbd>PgDn</kbd>/<kbd>&#8679;K</kbd>: Page down
- <kbd>Home</kbd>/<kbd>0</kbd>: First row
- <kbd>End</kbd>/<kbd>$</kbd>: Last row
The columns of the table
consist of the top-level attributes of the result-set. The partition key, sort key, plus any explicitly defined
attributes will always be displayed from the left margin onwards. The other attributes are determined
from the results of the last scan or query, and may change depending on the result.
The display columns of the table can be scrolled across by using the following keys:
- <kbd>&larr;</kbd>/<kbd>j</kbd>: Scroll to the left
- <kbd>&rarr;</kbd>/<kbd>l</kbd>: Scroll to the right
The attributes of the currently selected item will appear in the middle pane. Both the type and the value of each
attribute will be displayed. Any nested attributes will be indented, and will below their parent item. A value
displayed in grey does not represent the actual value of the item, but indicates some meta-information about the item,
such as the length.
## The Back-stack
Changes to the view of Dynamo-Browse will be maintained in back-stack, similar to how a
web-browse keeps track of the webpages you've visited. This stack will record the
currently viewed table, filter, or query, allowing you to "go back" to a previous view
by pressing <kbd>Backspace</kbd>. Pressing <kbd>\\</kbd> will allow you to go forward through the stack.
The back-stack is preserved in the workspace file, and can be restored by launching Dynamo-Browse with the `-w`
switch. Launching Dynamo-Browse with a workspace that has a non-empty stack will restore the last viewed
table, filter, or query from the session that was previously using the workspace.
{{<hint info>}}
**Note:** the back-stack does not preserve the actual items in the workspace. Going backwards or forwards
through the back-stack will execute any queries or filters against the actual table itself.
{{</hint>}}
## Adjusting The Layout
The horizontal size of the item table and currently selected item pane can be changed to one of the
following layout configurations:
- Item view taking up 14 rows on the bottom with the table pane taking up the rest of the vertical space (the default)
- Item view and table view taking up half of the available space
- Table view taking up 7 rows on the top with the item view taking up the rest of the vertical space
- Table view hidden
- Item view hidden
Pressing <kbd>w</kbd> will cycle forward though these layouts. For example, while in the
default layout, pressing <kbd>w</kbd> will switch to the second layout, where both the table view take up half the
screen. Pressing <kbd>&#8679;W</kbd> will cycle through the layouts in the reverse order.
## Adjusting The Displayed Columns
The columns of the result-set can be adjusted by opening up the _Fields Popup_. This popup can be opened by pressing <kbd>f</kbd>.
<figure class="screenshot">
<img src="/images/dynamo-browse/fields-popup.png" alt="dynamo-browse">
</figure>
While this popup is opened, the following changes can be applied to the displayed columns of the main table:
- Columns can be hidden
- The order columns appear in the main table can be rearranged
- New columns can be added
The popup will display the list of columns of the main result-set table. Pressing <kbd>&uarr;</kbd>/<kbd>i</kbd>
or <kbd>&darr;</kbd>/<kbd>k</kbd> will move the selection indicator to the column to apply the operation. Pressing
<kbd>&larr;</kbd>/<kbd>j</kbd> or <kbd>&rarr;</kbd>/<kbd>l</kbd> will scroll the main table left or right so that any
operations can be previewed.
To reset the columns to the top-level fields of the current result set, press <kbd>&#8679;R</kbd>.
To close the popup, press <kbd>Escape</kbd>.
### Showing And Hiding Columns
In the Fields Popup, each row has a symbol indicating whether the row is currently visible (`.`) or hidden (`✕`). Pressing
<kbd>Space</kbd> will toggle whether the currently selected column is shown or hidden.
### Re-arranging The Order Of Columns
The currently selected row can be moved up or down the table. This will move the corresponding column in the main table either
left or right.
Press <kbd>&#8679;I</kbd> to the selected row up, which will move the corresponding column left.
Press <kbd>&#8679;K</kbd> to the selected row down, which will move the corresponding column right.
### Adding And Removing Columns
New columns can be added in the table. The value of these columns will be determined by the result of a query expression,
and can be used to expose fields that are not at the top level.
Any nested fields of maps or lists will not be included as a column by default. Consider, for example, a table of books
with authors structured as so:
```
{
"book": {"S": "The Lord Of The Rings"},
"author": {"M": {
"firstName": {"S": "John"},
"middleName": {"S": "Ronald Reuel"},
"lastName": {"S": "Tolkien"},
}}
}
```
If you wanted to show the the author's first and last name in the main table, rather than just see the description `(3 items)`, you
can add a new column with an expression selecting the fields of the author map. The expressions that can be used here
are as follows:
- First name: `author.firstName`
- Last name: `author.lastName`
This can be extended to expressions that perform comparisons or operations. For example, the expression `author.firstName ^= "J"` can be
use in a new column to display `True` for any first name that begins with a J.
To add a new column, press <kbd>a</kbd> while the Fields Popup is visible. You'll be prompted to enter a query expression,
which will be evaluated over each row within the result-set when displaying the table.
Any column, that was either retrieved from the result-set or added by the user, can be deleted by selecting the column
within the Fields Popup and pressing <kbd>d</kbd>.
## Entering Commands
Commands can be entered by pressing <kbd>:</kbd> and entering the command, with any arguments, at the prompt.
The list of available commands can be found within the [reference section](/docs/reference/#commands).

View file

@ -1,66 +0,0 @@
---
title: Launching and Quitting
weight: 10
---
# Launching and Quitting
To launch Dynamo-Browse, run the following command at the terminal:
```
dynamo-browse
```
This will use your current AWS configuration and region, which can be changed by setting
the relevant `AWS_` environment variables.
To connect to a local instance of DynamoDB, such as one
running in a Docker container, use the `--local` flag. This takes as the argument the hostname
and endpoint of the local DynamoDB server. The hostname can be omitted, and will default to `localhost`:
```
dynamo-browse --local :8080
```
## Selecting a Table
Upon launch, Dynamo-Browse will present a list of all the tables within the region:
<figure class="screenshot">
<img src="/images/dynamo-browse/table-selection.png" alt="Table selection">
</figure>
Select the table to view by pressing <kbd>Enter</kbd>. Use the following keys to navigate
the items within the list:
- <kbd>&uarr;</kbd>/<kbd>i</kbd>: Move selection up
- <kbd>&darr;</kbd>/<kbd>k</kbd>: Move selection down
Once the table is selected, the table will be scanned and Dynamo-Browse will be presented in
[View Mode](#view-mode). Another table can be selected from within view mode using the `:table` command.
Dynamo-Browse can also be launched directly in view mode by specifying a table using the `-t` flag:
```
dynamo-browse -t user-accounts
```
## Selecting a Workspace
Dynamo-Browse tracks session state, such as the back-stack, in a workspace file. By default the workspace
file will be a new file created within the temporary directory, but a specific workspace filename can be
specified by using the `-w` flag:
```
dynamo-browse -w my-workspace.ws
```
If the workspace filename references an existing file, Dynamo-Browse will restore the workspace and use it for the duration of
the session. If the workspace filename references a non-existing file, Dynamo-Browse will initialise a new workspace
using the specified filename.
Only one running instance of Dynamo-Browse can use a single workspace file at any one time.
## Quitting
To quit dynamodb-browse, enter the command `q` by pressing <kbd>:</kbd>, then typing <kbd>q</kbd> <kbd>Enter</kbd>.
The keystroke <kbd>Ctrl+C</kbd> can also be used to quit.

View file

@ -1,30 +0,0 @@
---
weight: 20
---
## Linux
To install the Debian package, download the `.deb` file, and install the package by running:
```
sudo apt install ./audax_0.4.0_linux_amd64.deb
```
To install the RPM package, download the `.rpm` file, and install the package by running:
```
sudo yum install ./audax_0.4.0_linux_amd64.rpm
```
## Install Using Go
If you have Go 1.22, you can install using the following command:
```
go install github.com/lmika/audax/cmd/dynamo-browse@v0.4.0
```
The source code can be [found on GitHub](https://github.com/lmika/audax).

View file

@ -1,40 +0,0 @@
module: async
docs: |
Provides commands for executing blocks asynchronously.
Asynchronous blocks are executed in the same thread as regular commands, so it's recommended
to avoid long running operations in these blocks. Exception to these are commands which require fetching
data from the database.
symbols:
- name: do
syntax: async:do BLOCK
docs: |
Schedules a block to be executed at the conclusion of all other running commands.
Blocks are place in a queue in the order the call to `async:do` is made, with a maximum
queue size of 100 blocks. If the queue is full, the command will return an error.
example: |
async:do {
echo "World"
}
echo "Hello"
- name: in
syntax: async:in SECONDS BLOCK
docs: |
Schedules a block to be executed in SECONDS seconds. Once the timout has ellapsed, the block will
be placed on the queue and will be executed once all other pending blocks have been consumed.
example: |
async:in 5 {
echo "5 seconds have ellapsed"
}
- name: query
syntax: async:query EXPRESSION [QUERY_ARGS] BLOCK [OPTIONS]
docs: |
Executes the query in the background and schedules BLOCK once the resultset is available. The available
options match that of rs:query.
Note: the RC files are invoked before the table picker is shown, so any async:query calls invoked during
startup should have the `-table` option set.
example: |
async:query 'pk = $arg' [arg:"abc123"] { |rs|
echo $rs.First.pk
}

View file

@ -1,22 +0,0 @@
module: av
docs: |
Provides commands for translating between UCL and DynamoDB types.
symbols:
- name: 'true'
syntax: av:true
docs: |
Returns a true BOOL attribute value.
- name: 'false'
syntax: av:false
docs: |
Returns a false BOOL attribute value.
- name: 'null'
syntax: av:null
docs: |
Returns a NULL attribute value.
- name: string-set
syntax: av:string-set LIST
docs: |
Converts a list into a string set (SS) attribute value.
example: |
av:string-set [apple banana cherry]

View file

@ -1,44 +0,0 @@
module: item
type: type
docs: |
A single record from a DynamoDB table.
Item values are converted to tamarin types using the following:
| Attribute Type | Tamarin Type |
|:---------------|:-------------|
| S | string |
| N | int, float \[1\] |
| BOOL | bool |
| NULL | nil |
| L | list |
| M | map |
| SS | set, with string values |
| NS | set, with number values |
Notes:
- \[1\]: int will be used if the value can be parsed as an integer, otherwise it will be returned as a float.
- Byte array (B or BS) values are currently not supported.
symbols:
- name: resultset
syntax: item.resultset
docs: |
Returns the result-set this item is a member of.
- name: index
syntax: item.index
docs: |
Returns the index of this item within the result set.
- name: attr
syntax: item.attr(expression)
docs: |
Returns the attribute value from the query expression.
- name: set_attr
syntax: item.set_attr(expression, value)
docs: |
Sets the value of the attribute.
- name: delete_attr
syntax: item.delete_attr(expression)
docs: |
Delete the attribute.

View file

@ -1,9 +0,0 @@
module: opt
docs: |
Provides commands for modifying options.
symbols:
- name: 'set'
syntax: opt:set NAME [VALUE]
docs: |
Returns the current value of the setting option NAME. If VALUE is present, modifies the setting option first before
returning the value.

View file

@ -1,14 +0,0 @@
module: pb
docs: |
Provides access to the pasteboard.
symbols:
- name: 'paste'
syntax: pb:paste
docs: |
Returns the current text value of the paste board. If the paste board contains no text value,
or is unavailable, returns the empty string.
- name: 'copy'
syntax: pb:copy VALUE
docs: |
Sets the current text value of the paste board to VALUE. If the paste board is unavailable, this
command nops.

View file

@ -1,27 +0,0 @@
module: resultset
type: type
docs: |
Holds a collection of items returned from a query, or presented to a user.
A specific item of a result-set can be retrived using the subscript option. For example, `$result.(21)` will
return the 21st item of the result-set from the first item.
There is no guarantee to the ordering of items within the result-set, although items are usually
ordered based on the partition and sort key.
symbols:
- name: Items
syntax: '$resultset.Items'
docs: |
Returns the items within the result set.
- name: HasNextPage
syntax: '$resultset.HasNextPage'
docs: |
Returns true if this result-set has another page. The next page can be retrieved by calling `rs:next-page $resultset`.
- name: First
syntax: '$resultset.First'
docs: |
Returns the first item of the result-set, or nil if the result set is empty. Shorthand for `$result.Items.(0)`.
- name: Table
syntax: '$resultset.Table'
docs: |
Returns information about the table this result set belongs to.

View file

@ -1,37 +0,0 @@
module: rs
docs: |
Provides operations over result-sets, or commands which return result-sets.
symbols:
- name: new
syntax: rs:new [-table TABLE]
docs: |
Creates a new, empty result-set. If -table is specific, will configure the result-set table to
that of TABLE. Otherwise, the result-set will inherit the current table.
- name: query
syntax: rs:query EXPRESSION [ARGUMENTS] [-table TABLE]
docs: |
Invokes a query expression and returns the result as a result-set. The query can be invoked with
ARGUMENTS, which must be a dictionary with key/value pairs that are made available to the query
as either attribute placeholders (`:attr`), or value placeholders (`$value`).
If -table is specific, will run the query over TABLE. Otherwise, the query will be invoked
over the current table.
Note that this command blocks the command thread until either the results are available, or if
an error occurs. To avoid this, use 'async:query', which will run the query in the background.
example: |
results = rs:query 'pk = $myID' [myID:"abc132"] -table users
- name: scan
syntax: rs:scan [-table TABLE]
docs: |
Runs a table scan, returning the first page of results as a result-set. If -table is specific, will
run the scan over TABLE. Otherwise, the scan will be over the current table.
- name: next-page
syntax: rs:next-page RESULTSET
docs: |
Runs the next page of results from the passed in result-set. This will depend on how RESULTSET was
created. For example, if RESULTSET was from a query, this will return the next page of results from that
query. Likewise, for scans.
If the next page is available, the results will be returned as a new result-set, leaving the original result-set
unmodified. If no next page is available, then nil will be returned.

View file

@ -1,52 +0,0 @@
module: session
docs: |
Provides access to the currently viewed table and result-set.
symbols:
- name: query
syntax: session.query(expression, [options])
docs: |
Executes a query against a DynamoDB table. This returns a resultset if the query was successful.
A query with no results will be an empty result-set.
The _expression_ is the query expression to execute. This is similar to the type of expressions entered
after pression <kbd>?</kbd>.
The _options_ map can contain the following key/value pairs:
- `table`: the DynamoDB table to execute the query against. Default is the currently displayed table.
- `args`: A map containing names and values that can be used as placeholders in the query expression.
example: |
out := session.query("pk = $key", {
table: "some-table",
args: {
key: "my partition key"
}
}
session.set_result_set(out.unwrap())
- name: current_table
syntax: session.current_table()
docs: |
Returns information about the currently displayed table. This will be returned as a `table` object. If no
table is displayed, this function will return `nil`.
- name: resultset
syntax: session.resultset
docs: |
Returns the currently displayed result set. This is the set of items that are shown to the user in the items
table. This will be returned as a `resultset` object.
Note that this only contains the items of the current result set that exists in memory. As such, it will be
capped to the configured query limit.
- name: selected_item
syntax: session.selected_item()
docs: |
Returns the item currently highlighted in the items table. This will be returned as an `item` object. If no
item is highlighted, it will return `nil`.
- name: set_result_set
syntax: session.set_result_set(new_result_set)
docs: |
Replaces the currently displayed result-set with a new one. This can be used alongside the `query` function
to display the results of a query.
Changing the displayed result-set will trigger a redraw of the viewport and will push a new history record to
the backstack. Therefore, it's not recommended to call this method too often during a script execution session.
At most once with the final result-set you'd like to show the user is considered best practice.

View file

@ -1,20 +0,0 @@
module: table
type: type
docs: |
Provides information about a DynamoDB table.
symbols:
- name: name
syntax: table.name
docs: |
Returns the name of the table.
- name: keys
syntax: table.keys
docs: |
Returns the keys of the table. This will be returned as a map with the following names:
- `hash`: the attribute name of the partition (hash) key
- `range`: the attribute name of the sort (range) key, or `nil` if one is not defined.
- name: gsis
syntax: table.gsis
docs: |
Returns a list of the GSIs used by this table. The elements of the list will have the type table_index

View file

@ -1,16 +0,0 @@
module: table_index
type: type
docs: |
Provides information about an DynamoDB index.
symbols:
- name: name
syntax: table_index.name
docs: |
Returns the name of the index.
- name: keys
syntax: table_index.keys
docs: |
Returns the keys of the index. This will be returned as a map with the following names:
- `hash`: the attribute name of the partition (hash) key
- `range`: the attribute name of the sort (range) key, or `nil` if one is not defined.

View file

@ -1,18 +0,0 @@
module: ui
docs: |
Provides control over the user interface.
symbols:
- name: print
syntax: ui.print(args...)
docs: |
Displays a message in the status bar.
- name: prompt
syntax: ui.prompt(message)
docs: |
Request a line of input from the user, using _message_ as the prompt.
This function will return the user's input as a string, or `nil` if the user cancels
the prompt by pressing <kbd>Esc</kbd>
example: |
line := ui.prompt("What is your name? ")
ui.print("Hello, ", line)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View file

@ -1,3 +0,0 @@
{{- $options := dict "targetPath" "js/bundle.js" -}}
{{- $jsBundle := resources.Get "js/index.js" | js.Build $options | resources.Minify | fingerprint -}}
<script src="{{ $jsBundle.RelPermalink | absURL }}" defer></script>

View file

@ -1 +0,0 @@
<script src="https://tinylytics.app/embed/vgYK9BZh7G14oSKuW2wR.js" defer></script>

View file

@ -1,62 +0,0 @@
{{ define "main" }}
<article class="markdown">
{{ partial "docs/post-meta" . }}
{{- .Content -}}
<!-- API Data -->
{{ range sort $.Site.Data.scriptmods }}
<section>
{{ if eq .type "type" }}
<h2 id="type-{{.module}}">
Type: {{ .module }}
<a class="anchor" href="#type-{{.module}}">#</a>
</h2>
{{ else }}
<h2 id="module-{{.module}}">
Module: {{ .module }}
<a class="anchor" href="#module-{{.module}}">#</a>
</h2>
{{ end }}
{{ $.RenderString .docs }}
{{ $moduleName := .module }}
{{ range sort .symbols "name" }}
<h3 id="{{$moduleName}}-{{.name}}">
{{$moduleName}}.{{ .name }}
<a class="anchor" href="#{{$moduleName}}-{{.name}}">#</a>
</h3>
<pre><code>{{ .syntax }}</code></pre>
{{ $.RenderString .docs }}
{{if .example}}
<h4>Example</h4>
<pre><code>{{ .example }}</code></pre>
{{end}}
{{ end }}
</section>
{{end}}
</article>
{{ end }}
{{ define "toc" }}
<nav id="TableOfContents">
<ul>
{{ range sort $.Site.Data.scriptmods }}
{{ if eq .type "type" }}
<li><a href="#type-{{.module}}">{{ .module }}</a></li>
{{ else }}
<li><a href="#module-{{.module}}">{{ .module }}</a></li>
{{ end }}
{{/*
<ul>
{{ $moduleName := .module }}
{{ range sort .symbols "name" }}
<li><a href="#{{$moduleName}}-{{.name}}">{{ .name }}</a></li>
{{ end }}
</ul>
*/}}
{{ end }}
</ul>
</nav>
{{ end }}

View file

@ -1,28 +0,0 @@
{
"name": "awstools-web",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "awstools-web",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@hotwired/stimulus": "^3.1.0"
}
},
"node_modules/@hotwired/stimulus": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.1.0.tgz",
"integrity": "sha512-iDMHUhiEJ1xFeicyHcZQQgBzhtk5mPR0QZO3L6wtqzMsJEk2TKECuCQTGKjm+KJTHVY0dKq1dOOAWvODjpd2Mg=="
}
},
"dependencies": {
"@hotwired/stimulus": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.1.0.tgz",
"integrity": "sha512-iDMHUhiEJ1xFeicyHcZQQgBzhtk5mPR0QZO3L6wtqzMsJEk2TKECuCQTGKjm+KJTHVY0dKq1dOOAWvODjpd2Mg=="
}
}
}

View file

@ -1,25 +0,0 @@
{
"name": "awstools-web",
"version": "1.0.0",
"description": "Website for audax",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/lmika/audax-web.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/lmika/audax-web/issues"
},
"homepage": "https://github.com/lmika/audax-web#readme",
"dependencies": {
"@hotwired/stimulus": "^3.1.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View file

@ -4,17 +4,15 @@ import (
"context"
"flag"
"fmt"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"log"
"net"
"os"
"strings"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
tea "github.com/charmbracelet/bubbletea"
bus "github.com/lmika/events"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/logging"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/osstyle"
@ -35,6 +33,8 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/keybindings"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles"
bus "github.com/lmika/events"
"github.com/lmika/gopkgs/cli"
)
func main() {
@ -52,8 +52,7 @@ func main() {
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot load AWS config: %v", err)
os.Exit(1)
cli.Fatalf("cannot load AWS config: %v", err)
}
closeFn := logging.EnableLogging(*flagDebug)
@ -62,8 +61,7 @@ func main() {
wsManager := workspaces.New(workspaces.MetaInfo{Command: "dynamo-browse"})
ws, err := wsManager.OpenOrCreate(*flagWorkspace)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot create workspace: %v", ws)
os.Exit(1)
cli.Fatalf("cannot create workspace: %v", ws)
}
defer ws.Close()
@ -71,8 +69,7 @@ func main() {
if *flagLocal != "" {
host, port, err := net.SplitHostPort(*flagLocal)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid address '%v': %v", *flagLocal, err)
os.Exit(1)
cli.Fatalf("invalid address '%v': %v", *flagLocal, err)
}
if host == "" {
host = "localhost"
@ -97,14 +94,12 @@ func main() {
if *flagRO {
if err := settingStore.SetReadOnly(*flagRO); err != nil {
fmt.Fprintf(os.Stderr, "unable to set read-only mode: %v", err)
os.Exit(1)
cli.Fatalf("unable to set read-only mode: %v", err)
}
}
if *flagDefaultLimit > 0 {
if err := settingStore.SetDefaultLimit(*flagDefaultLimit); err != nil {
fmt.Fprintf(os.Stderr, "unable to set default limit: %v", err)
os.Exit(1)
cli.Fatalf("unable to set default limit: %v", err)
}
}
@ -135,32 +130,27 @@ func main() {
if *flagQuery != "" {
if *flagTable == "" {
fmt.Fprintf(os.Stderr, "-t will need to be set for -q")
os.Exit(1)
cli.Fatalf("-t will need to be set for -q")
}
ctx := context.Background()
query, err := queryexpr.Parse(*flagQuery)
if err != nil {
fmt.Fprintf(os.Stderr, "query: %v", err)
os.Exit(1)
cli.Fatalf("query: %v", err)
}
ti, err := tableService.Describe(ctx, *flagTable)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot describe table: %v", err)
os.Exit(1)
cli.Fatalf("cannot describe table: %v", err)
}
rs, err := tableService.ScanOrQuery(ctx, ti, query, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot execute query: %v", err)
os.Exit(1)
cli.Fatalf("cannot execute query: %v", err)
}
if err := exportController.ExportToWriter(os.Stdout, rs); err != nil {
fmt.Fprintf(os.Stderr, "cannot export results of query: %v", err)
os.Exit(1)
cli.Fatalf("cannot export results of query: %v", err)
}
return
}
@ -177,13 +167,11 @@ func main() {
keyBindingController,
pasteboardProvider,
settingsController,
itemRendererService,
)
commandController, err := commandctrl.NewCommandController(inputHistoryService, stdCommands)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot setup command controller: %v", err)
os.Exit(1)
cli.Fatalf("cannot setup command controller: %v", err)
}
commandController.SetCommandCompletionProvider(columnsController)

15
go.mod
View file

@ -1,8 +1,8 @@
module lmika.dev/cmd/dynamo-browse
go 1.25
go 1.24
toolchain go1.25.0
toolchain go1.24.0
require (
github.com/alecthomas/participle/v2 v2.1.1
@ -20,15 +20,16 @@ require (
github.com/charmbracelet/lipgloss v0.6.0
github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e
github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe
github.com/mattn/go-runewidth v0.0.14
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70
github.com/muesli/reflow v0.3.0
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.10.0
golang.design/x/clipboard v0.6.2
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a
ucl.lmika.dev v0.1.2
ucl.lmika.dev v0.1.0
)
require (
@ -50,12 +51,9 @@ require (
github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-co-op/gocron/v2 v2.17.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@ -66,7 +64,6 @@ require (
github.com/muesli/termenv v0.13.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sahilm/fuzzy v0.1.0 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
@ -80,5 +77,5 @@ require (
golang.org/x/text v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lmika.dev/pkg/modash v0.1.0 // indirect
lmika.dev/pkg/modash v0.0.0-20250619112300-0be0b6b35b1b // indirect
)

38
go.sum
View file

@ -75,8 +75,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
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/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
@ -86,16 +84,12 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o=
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@ -111,6 +105,8 @@ github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGy
github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM=
github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 h1:dtMPRNoDqDnnP3HgOvYhswcJVSqdISkYlCtGOjTqg6Q=
github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg=
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f h1:tz68Lhc1oR15HVz69IGbtdukdH0x70kBDEvvj5pTXyE=
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f/go.mod h1:zHQvhjGXRro/Xp2C9dbC+ZUpE0gL4GYW75x1lk7hwzI=
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18=
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe/go.mod h1:qpdOkLougV5Yry4Px9f1w1pNMavcr6Z67VW5Ro+vW5I=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@ -152,18 +148,16 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
@ -255,9 +249,21 @@ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lmika.dev/pkg/modash v0.1.0 h1:fltroSvP0nKj9K0E6G+S9LULvB9Qhj47+SZ2b9v/v/c=
lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=
ucl.lmika.dev v0.1.1 h1:P8nEqJPKS+wmXZiSjEmJkOUeWQF9YxWSymDkLXt9mvg=
ucl.lmika.dev v0.1.1/go.mod h1:f5RzeCTyBO+4k6LYFuDkwGRujnj4/4ONM60AEtQj02k=
ucl.lmika.dev v0.1.2 h1:dTqLKGw/pPqE7UrkrJd5qPu2i6BTDzJLaM0cRkJGn6A=
ucl.lmika.dev v0.1.2/go.mod h1:f5RzeCTyBO+4k6LYFuDkwGRujnj4/4ONM60AEtQj02k=
lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d h1:x5aMBOkCr4cjJyFmq+qJVUsByfffD9k56HYDx1yZSR4=
lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=
lmika.dev/pkg/modash v0.0.0-20250619112300-0be0b6b35b1b h1:Oymcj66pgyJ2CtGk9lPh06P4FOekllE1iPehDwaL0vw=
lmika.dev/pkg/modash v0.0.0-20250619112300-0be0b6b35b1b/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e h1:N+HzQUunDUvdjAzbSDtHQZVZ1k+XHbVgbNwmc+EKmlQ=
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250527110948-e869e6c9bd4d h1:SlmmY92u7nvPW6xa66n2ZPfCOx90uNp1KkJZ1IDF6K0=
ucl.lmika.dev v0.0.0-20250527110948-e869e6c9bd4d/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250527112110-03e6878524a1 h1:e++1/TfwVKdWi1TmO+kfCdO2+lCTKCrh1m4ps0p7UUM=
ucl.lmika.dev v0.0.0-20250527112110-03e6878524a1/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250527114213-41b4fdb00382 h1:rDJtNrcKVmEqLep1l2YrodPjCfq+/yl7p8EZUrKW7Aw=
ucl.lmika.dev v0.0.0-20250527114213-41b4fdb00382/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250528113931-3a88c0c777d8 h1:kC312X0SvM9YHtuS1r6Js+CgmSS+kSAMLj8cYFuI0+4=
ucl.lmika.dev v0.0.0-20250528113931-3a88c0c777d8/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250718121358-7c76e61b08e4 h1:4HF6Av2/cOXBmRfHBthHn2iHJhk9GvHAFg6Tu6LVUTA=
ucl.lmika.dev v0.0.0-20250718121358-7c76e61b08e4/go.mod h1:+HB5VAi0cI28mr3LbclJvv5lb/HclJ3R60x6cbjgt4c=
ucl.lmika.dev v0.1.0 h1:gIZvLjruY1buIH25cm1hcIOvZ/+BvsZ+f84xrhcS6pY=
ucl.lmika.dev v0.1.0/go.mod h1:+HB5VAi0cI28mr3LbclJvv5lb/HclJ3R60x6cbjgt4c=

View file

@ -1,102 +0,0 @@
version: 2
builds:
- id: dynamo-browse_macos
targets:
- darwin_amd64
- darwin_arm64
env:
- CGO_ENABLED=0
main: ./cmd/dynamo-browse/.
binary: dynamo-browse
- id: dynamo-browse_linux
targets:
- linux_amd64
- linux_arm64
env:
- CGO_ENABLED=0
main: ./cmd/dynamo-browse/.
binary: dynamo-browse
# MacOS Release: notarise and publish via Homebrew Tap
notarize:
macos:
- enabled: true
ids:
- dynamo-browse_macos
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
notarize:
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
key: "{{.Env.MACOS_NOTARY_KEY}}"
wait: true
timeout: 20m
archives:
- id: macos_tgz
ids:
- dynamo-browse_macos
wrap_in_directory: false
formats:
- tar.gz
- id: linux_tgz
ids:
- dynamo-browse_linux
wrap_in_directory: false
formats:
- tar.gz
homebrew_casks:
- name: dynamo-browse
ids:
- macos_tgz
repository:
owner: casks
name: dynamo-browse
git:
url: 'forgejo@lmika.dev:casks/dynamo-browse.git'
private_key: "{{ .Env.HOMEBREW_TAP_PRIVATE_KEY }}"
directory: Casks
homepage: https://dynamo-browse.lmika.dev/
description: TUI tools for working with DynamoDB
license: MIT
# Linux releases: publish as deb and rpm packages
nfpms:
- id: package_nfpms
package_name: audax
ids:
- dynamo-browse_linux
vendor: lmika
homepage: https://dynamo-browse.lmika.dev/
maintainer: Leon Mika <lmika@lmika.org>
description: TUI tools for working with DynamoDB
license: MIT
formats:
- deb
- rpm
bindir: /usr/local/bin
release:
gitea:
owner: cmd
name: dynamo-browse
ids:
- macos_tgz
- linux_tgz
checksum:
name_template: 'checksums.txt'
snapshot:
version_template: "{{ .Tag }}-next"
gitea_urls:
api: https://lmika.dev/api/v1
download: https://lmika.dev
# set to true if you use a self-signed certificate
skip_tls_verify: false

View file

@ -1,96 +0,0 @@
package cmdpacks
import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/ucl"
)
type asyncModule struct {
tableService *tables.Service
state *controllers.State
}
func (m asyncModule) asyncDo(ctx context.Context, args ucl.CallArgs) (any, error) {
var block ucl.Invokable
if err := args.Bind(&block); err != nil {
return nil, err
}
return nil, commandctrl.ScheduleTask(ctx, func(ctx context.Context) error {
_, err := block.Invoke(ctx)
return err
})
}
func (m asyncModule) asyncIn(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
duration int
block ucl.Invokable
)
if err := args.Bind(&duration, &block); err != nil {
return nil, err
}
_, err := commandctrl.CronScheduler(ctx).NewJob(
gocron.OneTimeJob(
gocron.OneTimeJobStartDateTime(time.Now().Add(time.Duration(duration)*time.Second)),
),
gocron.NewTask(func(ctx context.Context) {
commandctrl.ScheduleTask(ctx, func(ctx context.Context) error {
_, err := block.Invoke(ctx)
return err
})
}),
gocron.WithContext(ctx),
)
return nil, err
}
func (m asyncModule) asyncQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
block ucl.Invokable
)
args, q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService, 1)
if err != nil {
return nil, err
}
if err := args.Bind(&block); err != nil {
return nil, err
}
return nil, commandctrl.ScheduleAuxTask(ctx, "query: "+q.String(), func(ctx context.Context) error {
newResultSet, err := m.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil)
if err != nil {
return err
}
return commandctrl.ScheduleTask(ctx, func(ctx context.Context) error {
_, err := block.Invoke(ctx, newResultSetProxy(newResultSet))
return err
})
})
}
func moduleAsync(tableService *tables.Service, state *controllers.State) ucl.Module {
m := asyncModule{
state: state,
tableService: tableService,
}
return ucl.Module{
Name: "async",
Builtins: map[string]ucl.BuiltinHandler{
"do": m.asyncDo,
"in": m.asyncIn,
"query": m.asyncQuery,
},
}
}

View file

@ -2,7 +2,6 @@ package cmdpacks
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"ucl.lmika.dev/ucl"
)

View file

@ -2,7 +2,6 @@ package cmdpacks
import (
"context"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"ucl.lmika.dev/ucl"
@ -12,7 +11,7 @@ type optModule struct {
settingsController *controllers.SettingsController
}
func (m optModule) optSet(ctx context.Context, args ucl.CallArgs) (any, error) {
func (m optModule) pbSet(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
name string
newVale string
@ -42,7 +41,7 @@ func moduleOpt(
return ucl.Module{
Name: "opt",
Builtins: map[string]ucl.BuiltinHandler{
"set": m.optSet,
"set": m.pbSet,
},
}
}

View file

@ -2,8 +2,6 @@ package cmdpacks
import (
"context"
"time"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
@ -11,6 +9,7 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"time"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
)
@ -72,22 +71,21 @@ func parseQuery(
args ucl.CallArgs,
currentRS *models.ResultSet,
tablesService *tables.Service,
extraArgs int,
) (ucl.CallArgs, *queryexpr.QueryExpr, *models.TableInfo, error) {
) (*queryexpr.QueryExpr, *models.TableInfo, error) {
var expr string
if err := args.Bind(&expr); err != nil {
return args, nil, nil, err
return nil, nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return args, nil, nil, err
return nil, nil, err
}
if args.NArgs() > extraArgs {
if args.NArgs() > 0 {
var queryArgs ucl.Hashable
if err := args.Bind(&queryArgs); err != nil {
return args, nil, nil, err
return nil, nil, err
}
queryNames := map[string]string{}
@ -99,15 +97,12 @@ func parseQuery(
queryNames[k] = v.String()
switch t := v.(type) {
switch v.(type) {
case ucl.StringObject:
queryValues[k] = &types.AttributeValueMemberS{Value: v.String()}
case ucl.IntObject:
queryValues[k] = &types.AttributeValueMemberN{Value: v.String()}
case ucl.BoolObject:
queryValues[k] = &types.AttributeValueMemberBOOL{Value: t.Truthy()}
case attributeValueProxy:
queryValues[k] = t.value
// TODO: other types
}
return nil
})
@ -119,24 +114,24 @@ func parseQuery(
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return args, nil, nil, err
return nil, nil, err
}
tableInfo, err = tablesService.Describe(ctx, tblName)
if err != nil {
return args, nil, nil, err
return nil, nil, err
}
} else if currentRS != nil && currentRS.TableInfo != nil {
tableInfo = currentRS.TableInfo
} else {
return args, nil, nil, errors.New("no table specified")
return nil, nil, errors.New("no table specified")
}
return args, q, tableInfo, nil
return q, tableInfo, nil
}
func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
_, q, tableInfo, err := parseQuery(ctx, args, rs.state.ResultSet(), rs.tableService, 0)
q, tableInfo, err := parseQuery(ctx, args, rs.state.ResultSet(), rs.tableService)
if err != nil {
return nil, err
}
@ -187,7 +182,7 @@ func (rs *rsModule) rsScan(ctx context.Context, args ucl.CallArgs) (_ any, err e
return newResultSetProxy(newResultSet), nil
}
func (rs *rsModule) rsHide(ctx context.Context, args ucl.CallArgs) (any, error) {
func (rs *rsModule) rsFilter(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
rsProxy SimpleProxy[*models.ResultSet]
filter string
@ -307,7 +302,7 @@ func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module
"new": m.rsNew,
"query": m.rsQuery,
"scan": m.rsScan,
"hide": m.rsHide,
"filter": m.rsFilter,
"next-page": m.rsNextPage,
"union": m.rsUnion,
"set": m.rsSet,

View file

@ -154,40 +154,6 @@ func TestModRS_Query(t *testing.T) {
}
}
func TestModRS_Hide(t *testing.T) {
tests := []struct {
descr string
cmd string
}{
{
descr: "returns hidden items 1",
cmd: `
rs = rs:scan -table service-test-data
rs = rs:hide $rs 'pk="abc"'
assert (len $rs) "expected len == 2"
assert (eq $rs.First.pk "abc") "expected First.pk == abc"
`,
},
//{
// descr: "returns filtered items 2",
// cmd: `
// rs = rs:scan -table service-test-data
// rs = rs:filter $rs 'pk="bbb"'
// assert (len $rs) "expected len == 1"
// assert (eq $rs.First.pk "bbb") "expected First.pk == bbb"
// `,
//},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
svc := newService(t)
_, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd)
assert.NoError(t, err)
})
}
}
func TestModRS_First(t *testing.T) {
tests := []struct {
descr string
@ -214,14 +180,6 @@ func TestModRS_First(t *testing.T) {
rs = rs:query 'pk="zzz"' -table service-test-data
assert (eq $rs.First ()) "expected First to be nil"
`,
}, {
descr: "returns the first item using placeholders",
cmd: `
rs = rs:query 'pk=$v and sk=$u' [v:"abc" u:"222"] -table service-test-data
assert (eq $rs.First.pk "abc") "expected First.pk == abc"
assert (eq $rs.First.sk "222") "expected First.sk == 222"
assert (eq $rs.First.beta 1231) "expected First.beta == 1231"
`,
},
}
for _, tt := range tests {

View file

@ -2,14 +2,10 @@ package cmdpacks
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/ucl"
)
@ -19,7 +15,6 @@ type uiModule struct {
state *controllers.State
ckb *customKeyBinding
readController *controllers.TableReadController
itemRenderer *itemrenderer.Service
}
func (m *uiModule) uiCommand(ctx context.Context, args ucl.CallArgs) (any, error) {
@ -43,26 +38,16 @@ func (m *uiModule) uiPrompt(ctx context.Context, args ucl.CallArgs) (any, error)
}
resChan := make(chan string)
cancelChan := make(chan struct{})
go func() {
commandctrl.PostMsg(ctx, events.PromptForInputMsg{
Prompt: prompt,
OnDone: func(value string) tea.Msg {
resChan <- value
return nil
},
OnCancel: func() tea.Msg {
cancelChan <- struct{}{}
return nil
},
})
commandctrl.PostMsg(ctx, events.PromptForInput(prompt, nil, func(value string) tea.Msg {
resChan <- value
return nil
}))
}()
select {
case value := <-resChan:
return value, nil
case <-cancelChan:
return nil, nil
case <-ctx.Done():
return nil, ctx.Err()
}
@ -90,38 +75,6 @@ func (m *uiModule) uiConfirm(ctx context.Context, args ucl.CallArgs) (any, error
}
}
func (m *uiModule) uiInKey(ctx context.Context, args ucl.CallArgs) (any, error) {
var prompt string
if err := args.Bind(&prompt); err != nil {
return nil, err
}
resChan := make(chan string)
cancelChan := make(chan struct{})
go func() {
commandctrl.PostMsg(ctx, events.PromptForKeyMsg{
Prompt: prompt,
OnDone: func(value string) tea.Msg {
resChan <- value
return nil
},
OnCancel: func() tea.Msg {
cancelChan <- struct{}{}
return nil
},
})
}()
select {
case value := <-resChan:
return value, nil
case <-cancelChan:
return nil, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiPromptTable(ctx context.Context, args ucl.CallArgs) (any, error) {
tables, err := m.tableService.ListTables(context.Background())
if err != nil {
@ -175,7 +128,7 @@ func (m *uiModule) uiBind(ctx context.Context, args ucl.CallArgs) (any, error) {
}
func (m *uiModule) uiQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
_, q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService, 0)
q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService)
if err != nil {
return nil, err
}
@ -195,36 +148,15 @@ func (m *uiModule) uiFilter(ctx context.Context, args ucl.CallArgs) (any, error)
return nil, nil
}
func (m *uiModule) uiSetItemAnnotator(ctx context.Context, args ucl.CallArgs) (any, error) {
var inv ucl.Invokable
if err := args.Bind(&inv); err != nil {
return nil, err
}
m.itemRenderer.SetAnnotation(itemrenderer.AnnotationFunc(func(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string {
v, err := inv.Invoke(ctx, newResultSetProxy(rs), itemProxy{rs, 0, item}, attrPathProxy{attrPath: &path})
if err != nil {
return ""
} else if v == nil {
return ""
}
return fmt.Sprint(v)
}))
commandctrl.QueueRefresh(ctx)
return nil, nil
}
func moduleUI(
tableService *tables.Service,
state *controllers.State,
readController *controllers.TableReadController,
itemRenderer *itemrenderer.Service,
) (ucl.Module, controllers.CustomKeyBindingSource) {
m := &uiModule{
tableService: tableService,
state: state,
readController: readController,
itemRenderer: itemRenderer,
ckb: &customKeyBinding{
bindings: map[string]tea.Cmd{},
keyBindings: map[string]string{},
@ -234,15 +166,13 @@ func moduleUI(
return ucl.Module{
Name: "ui",
Builtins: map[string]ucl.BuiltinHandler{
"command": m.uiCommand,
"prompt": m.uiPrompt,
"prompt-table": m.uiPromptTable,
"prompt-keypress": m.uiInKey,
"confirm": m.uiConfirm,
"query": m.uiQuery,
"filter": m.uiFilter,
"bind": m.uiBind,
"set-item-annotator": m.uiSetItemAnnotator,
"command": m.uiCommand,
"prompt": m.uiPrompt,
"prompt-table": m.uiPromptTable,
"confirm": m.uiConfirm,
"query": m.uiQuery,
"filter": m.uiFilter,
"bind": m.uiBind,
},
}, m.ckb
}

View file

@ -174,31 +174,6 @@ func (tp resultSetItemsProxy) Index(k int) ucl.Object {
return itemProxy{resultSet: tp.resultSet, idx: k, item: tp.resultSet.Items()[k]}
}
type resultSetMarkedItemsProxy struct {
resultSet *models.ResultSet
}
func (ip resultSetMarkedItemsProxy) String() string {
return fmt.Sprintf("MarkedItems(%v)", len(ip.resultSet.MarkedItems()))
}
func (ip resultSetMarkedItemsProxy) Truthy() bool {
return len(ip.resultSet.MarkedItems()) > 0
}
func (tp resultSetMarkedItemsProxy) Len() int {
return len(tp.resultSet.MarkedItems())
}
func (tp resultSetMarkedItemsProxy) Index(k int) ucl.Object {
markedItems := tp.resultSet.MarkedItems()
if k >= len(markedItems) {
return nil
}
actualItem := tp.resultSet.Items()[markedItems[k].Index]
return itemProxy{resultSet: tp.resultSet, idx: markedItems[k].Index, item: actualItem}
}
type itemProxy struct {
resultSet *models.ResultSet
idx int
@ -234,49 +209,6 @@ func (tp itemProxy) Each(fn func(k string, v ucl.Object) error) error {
return nil
}
type attrPathProxy struct {
attrPath *models.AttrPathNode
}
func (ap attrPathProxy) String() string {
return "RSItem()"
}
func (ap attrPathProxy) Truthy() bool {
return true
}
func (ap attrPathProxy) Len() (l int) {
for p := ap.attrPath; p != nil; p = p.Parent {
l++
}
return
}
func (ap attrPathProxy) Index(k int) ucl.Object {
if k == -1 {
return ucl.StringObject(ap.attrPath.Key)
}
if k >= 0 {
k = ap.Len() - k - 1
} else {
k = -k - 1
}
if k < 0 {
return nil
}
for p := ap.attrPath; p != nil; p = p.Parent {
if k <= 0 {
return ucl.StringObject(p.Key)
}
k -= 1
}
return nil
}
type attributeValueProxy struct {
value types.AttributeValue
}

View file

@ -1,101 +0,0 @@
package cmdpacks
import (
"testing"
"github.com/stretchr/testify/assert"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
)
func TestAttrPathProxy_Index(t *testing.T) {
tests := []struct {
descr string
attrPath models.AttrPathNode
index int
want string
wantNil bool
}{
{
descr: "return leaf 1",
attrPath: models.AttrPathNode{Key: "leaf"},
index: -1,
want: "leaf",
},
{
descr: "return leaf 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: -1,
want: "leaf",
},
{
descr: "return parent 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: -2,
want: "parent",
},
{
descr: "return parent 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: -2,
want: "parent",
},
{
descr: "return parent 3",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: -3,
want: "grandparent",
},
{
descr: "return root 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: 0,
want: "grandparent",
},
{
descr: "return root 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: 0,
want: "parent",
},
{
descr: "return root 3",
attrPath: models.AttrPathNode{Key: "leaf"},
index: 0,
want: "leaf",
},
{
descr: "return first child 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: 1,
want: "parent",
},
{
descr: "return first child 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: 1,
want: "leaf",
},
{
descr: "return nil 1",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent", Parent: &models.AttrPathNode{Key: "grandparent"}}},
index: -5,
wantNil: true,
},
{
descr: "return nil 2",
attrPath: models.AttrPathNode{Key: "leaf", Parent: &models.AttrPathNode{Key: "parent"}},
index: 56,
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
proxy := attrPathProxy{&tt.attrPath}
if tt.wantNil {
assert.Nil(t, proxy.Index(tt.index))
} else {
assert.Equal(t, tt.want, proxy.Index(tt.index).String())
}
})
}
}

View file

@ -2,53 +2,20 @@ package cmdpacks
import (
"context"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/pkg/errors"
)
type tablePVar struct {
state *controllers.State
tableService *tables.Service
readController *controllers.TableReadController
state *controllers.State
}
func (rs tablePVar) Get(ctx context.Context) (any, error) {
return newTableProxy(rs.state.ResultSet().TableInfo), nil
}
func (rs tablePVar) Set(ctx context.Context, value any) error {
scanNewTable := func(name string) error {
tableInfo, err := rs.tableService.Describe(ctx, name)
if err != nil {
return errors.Wrapf(err, "cannot describe %v", name)
}
resultSet, err := rs.tableService.Scan(ctx, tableInfo)
if resultSet != nil {
resultSet = rs.tableService.Filter(resultSet, rs.state.Filter())
}
msg := rs.readController.SetResultSet(resultSet)
commandctrl.PostMsg(ctx, msg)
return nil
}
tblVal, ok := value.(SimpleProxy[*models.TableInfo])
if ok {
return scanNewTable(tblVal.value.Name)
}
strVal, ok := value.(string)
if ok {
return scanNewTable(strVal)
}
return errors.New("new value to @table is not a table name")
}
type resultSetPVar struct {
state *controllers.State
readController *controllers.TableReadController
@ -69,15 +36,6 @@ func (rs resultSetPVar) Set(ctx context.Context, value any) error {
return nil
}
type markedSetPVar struct {
state *controllers.State
readController *controllers.TableReadController
}
func (rs markedSetPVar) Get(ctx context.Context) (any, error) {
return resultSetMarkedItemsProxy{rs.state.ResultSet()}, nil
}
type itemPVar struct {
state *controllers.State
}
@ -85,14 +43,9 @@ type itemPVar struct {
func (rs itemPVar) Get(ctx context.Context) (any, error) {
selItem, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, nil
return nil, errors.New("no item selected")
}
rset := rs.state.ResultSet()
if selItem < 0 || selItem >= len(rs.state.ResultSet().Items()) {
return nil, nil
}
return itemProxy{rset, selItem, rset.Items()[selItem]}, nil
return itemProxy{rs.state.ResultSet(), selItem, rs.state.ResultSet().Items()[selItem]}, nil
}
func (rs itemPVar) Set(ctx context.Context, value any) error {

View file

@ -1,32 +0,0 @@
package cmdpacks_test
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPVars(t *testing.T) {
tests := []struct {
descr string
cmd string
}{
{
descr: "returns item on empty result set",
cmd: `
ui:query '"a"="1"' -table service-test-data
@item
`,
},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
svc := newService(t)
ctx := t.Context()
_, err := svc.CommandController.ExecuteAndWait(ctx, tt.cmd)
assert.NoError(t, err)
})
}
}

View file

@ -2,14 +2,12 @@ package cmdpacks
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
@ -24,7 +22,6 @@ type StandardCommands struct {
KeyBindingController *controllers.KeyBindingController
PBProvider services.PasteboardProvider
SettingsController *controllers.SettingsController
ItemRenderer *itemrenderer.Service
modUI ucl.Module
}
@ -38,9 +35,8 @@ func NewStandardCommands(
keyBindingController *controllers.KeyBindingController,
pbProvider services.PasteboardProvider,
settingsController *controllers.SettingsController,
itemRenderer *itemrenderer.Service,
) StandardCommands {
modUI, ckbs := moduleUI(tableService, state, readController, itemRenderer)
modUI, ckbs := moduleUI(tableService, state, readController)
keyBindingController.SetCustomKeyBindingSource(ckbs)
return StandardCommands{
@ -52,7 +48,6 @@ func NewStandardCommands(
KeyBindingController: keyBindingController,
PBProvider: pbProvider,
SettingsController: settingsController,
ItemRenderer: itemRenderer,
modUI: modUI,
}
}
@ -404,7 +399,6 @@ func (sc StandardCommands) InstOptions() []ucl.InstOption {
ucl.WithModule(modulePB(sc.PBProvider)),
ucl.WithModule(moduleOpt(sc.SettingsController)),
ucl.WithModule(moduleAttrValue()),
ucl.WithModule(moduleAsync(sc.TableService, sc.State)),
}
}
@ -432,9 +426,8 @@ func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) {
ucl.SetBuiltin("q", sc.cmdQuit)
ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController})
ucl.SetPseudoVar("table", tablePVar{sc.State, sc.TableService, sc.ReadController})
ucl.SetPseudoVar("table", tablePVar{sc.State})
ucl.SetPseudoVar("item", itemPVar{sc.State})
ucl.SetPseudoVar("marked", markedSetPVar{sc.State, sc.ReadController})
}
func (sc StandardCommands) RunPrelude(ctx context.Context, ucl *ucl.Inst) error {
@ -445,13 +438,4 @@ func (sc StandardCommands) RunPrelude(ctx context.Context, ucl *ucl.Inst) error
const uclPrelude = `
ui:command unmark { mark none }
ui:command set-opt { |n k| opt:set $n $k }
ui:bind "view.toggle-marked-items" "M" {
markedCount = len @marked
if (eq $markedCount (len @resultset)) {
mark none
} else {
mark all
}
}
`

View file

@ -2,8 +2,6 @@ package cmdpacks_test
import (
"fmt"
"testing"
tea "github.com/charmbracelet/bubbletea"
bus "github.com/lmika/events"
"github.com/stretchr/testify/assert"
@ -23,6 +21,7 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/keybindings"
"lmika.dev/cmd/dynamo-browse/test/testdynamo"
"lmika.dev/cmd/dynamo-browse/test/testworkspace"
"testing"
)
func TestStdCmds_Mark(t *testing.T) {
@ -163,7 +162,6 @@ func newService(t *testing.T, opts ...serviceOpt) *services {
keyBindingController,
testPB,
settingsController,
itemRendererService,
),
)

View file

@ -4,14 +4,12 @@ import (
"bytes"
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/pkg/errors"
"log"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/go-co-op/gocron/v2"
"github.com/pkg/errors"
"ucl.lmika.dev/ucl"
"ucl.lmika.dev/ucl/builtins"
@ -19,22 +17,12 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
)
const (
commandsCategory = "commands"
pendingTaskBuffer = 100
pendingAuxTaskBuffer = 100
auxWorkers = 4
)
const commandsCategory = "commands"
type cmdMessage struct {
cmd string
}
type pendingTask struct {
descr string
task func(ctx context.Context) error
}
type CommandController struct {
uclInst *ucl.Inst
historyProvider IterProvider
@ -42,29 +30,19 @@ type CommandController struct {
lookupExtensions []CommandLookupExtension
completionProvider CommandCompletionProvider
uiStateProvider UIStateProvider
cronScheduler gocron.Scheduler
cmdChan chan cmdMessage
pendingTaskChan chan pendingTask
pendingAuxTaskChan chan pendingTask
msgChan chan tea.Msg
interactive bool
}
func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*CommandController, error) {
sched, err := gocron.NewScheduler()
if err != nil {
return nil, err
}
cc := &CommandController{
historyProvider: historyProvider,
commandList: nil,
lookupExtensions: nil,
cronScheduler: sched,
cmdChan: make(chan cmdMessage),
pendingTaskChan: make(chan pendingTask, pendingTaskBuffer),
pendingAuxTaskChan: make(chan pendingTask, pendingAuxTaskBuffer),
msgChan: make(chan tea.Msg),
interactive: true,
historyProvider: historyProvider,
commandList: nil,
lookupExtensions: nil,
cmdChan: make(chan cmdMessage),
msgChan: make(chan tea.Msg),
interactive: true,
}
options := []ucl.InstOption{
@ -97,8 +75,6 @@ func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*C
}
go cc.cmdLooper()
go cc.auxCmdLooper()
sched.Start()
return cc, nil
}
@ -196,13 +172,12 @@ func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea
}
func (c *CommandController) cmdLooper() {
ctx := context.Background()
execCtx := execContext{ctrl: c}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
for {
select {
case cmdChan := <-c.cmdChan:
execCtx := execContext{ctrl: c}
ctx := context.WithValue(ctx, commandCtlKey, &execCtx)
res, err := c.ExecuteAndWait(ctx, cmdChan.cmd)
if err != nil {
c.postMessage(events.Error(err))
@ -212,16 +187,6 @@ func (c *CommandController) cmdLooper() {
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
case task := <-c.pendingTaskChan:
execCtx := execContext{ctrl: c}
ctx := context.WithValue(ctx, commandCtlKey, &execCtx)
if err := task.task(ctx); err != nil {
c.postMessage(events.Error(err))
}
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
}
}
}
@ -340,13 +305,15 @@ func (c *CommandController) cmdInvoker(ctx context.Context, name string, args uc
}
func (c *CommandController) printLine(s string) {
log.Println(s)
if c.msgChan == nil || !c.interactive {
log.Println(s)
return
}
select {
case c.msgChan <- events.StatusMsg(s):
default:
log.Println(s)
}
}
@ -358,21 +325,6 @@ func (c *CommandController) postMessage(msg tea.Msg) {
c.msgChan <- msg
}
func (c *CommandController) auxCmdLooper() {
ctx := context.WithValue(context.Background(), commandCtlKey, &execContext{ctrl: c})
for i := 0; i < auxWorkers; i++ {
go func() {
for auxTask := range c.pendingAuxTaskChan {
log.Printf("running aux task: %v", auxTask.descr)
if err := auxTask.task(ctx); err != nil {
log.Printf("aux task error: %v", err)
}
}
}()
}
}
type teaMsgWrapper struct {
msg tea.Msg
}

View file

@ -2,10 +2,7 @@ package commandctrl
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/go-co-op/gocron/v2"
"github.com/pkg/errors"
"ucl.lmika.dev/ucl"
)
@ -60,40 +57,6 @@ func QueueRefresh(ctx context.Context) {
cmdCtl.requestRefresh = true
}
func CronScheduler(ctx context.Context) gocron.Scheduler {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return nil
}
return cmdCtl.ctrl.cronScheduler
}
func ScheduleTask(ctx context.Context, task func(ctx context.Context) error) error {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return errors.New("no command controller")
}
select {
case cmdCtl.ctrl.pendingTaskChan <- pendingTask{task: task}:
return nil
default:
return errors.New("task queue is full")
}
}
func ScheduleAuxTask(ctx context.Context, descr string, task func(ctx context.Context) error) error {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return errors.New("no command controller")
}
select {
case cmdCtl.ctrl.pendingAuxTaskChan <- pendingTask{descr: descr, task: task}:
return nil
default:
return errors.New("aux task queue is full")
}
}
type Invoker interface {
Invoke(invokable ucl.Invokable, args []any) tea.Msg
Inst() *ucl.Inst

View file

@ -1,10 +1,9 @@
package events
import (
"log"
tea "github.com/charmbracelet/bubbletea"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services"
"log"
)
func Error(err error) tea.Msg {
@ -32,23 +31,10 @@ func PromptForInput(prompt string, history services.HistoryProvider, onDone func
}
}
func PromptForKey(prompt string, onDone func(key string) tea.Msg) tea.Msg {
return PromptForKeyMsg{
Prompt: prompt,
OnDone: onDone,
}
}
func Confirm(prompt string, onResult func(yes bool) tea.Msg) tea.Msg {
return PromptForInputMsg{
Prompt: prompt,
OnDone: func(value string) tea.Msg {
return onResult(value == "y")
},
OnCancel: func() tea.Msg {
return onResult(false)
},
}
return PromptForInput(prompt, nil, func(value string) tea.Msg {
return onResult(value == "y")
})
}
func ConfirmYes(prompt string, onYes func() tea.Msg) tea.Msg {

View file

@ -27,10 +27,3 @@ type PromptForInputMsg struct {
OnCancel func() tea.Msg
OnTabComplete func(value string) (string, bool)
}
// PromptForKey indicates that the context is requesting a single key press
type PromptForKeyMsg struct {
Prompt string
OnDone func(key string) tea.Msg
OnCancel func() tea.Msg
}

View file

@ -10,8 +10,6 @@ import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
tea "github.com/charmbracelet/bubbletea"
bus "github.com/lmika/events"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/attrcodec"
@ -22,6 +20,8 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/inputhistory"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/viewsnapshot"
bus "github.com/lmika/events"
"github.com/pkg/errors"
)
type resultSetUpdateOp int

View file

@ -1,6 +0,0 @@
package models
type AttrPathNode struct {
Key string
Parent *AttrPathNode
}

View file

@ -1,11 +1,9 @@
package models
import (
"sort"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"sort"
"time"
)
type ResultSet struct {
@ -22,10 +20,6 @@ type ResultSet struct {
columns []string
sortCriteria SortCriteria
mutex sync.Mutex
cachedMarkedItems []ItemIndex
hasCachedMarkedItems bool
}
type Queryable interface {
@ -53,11 +47,6 @@ func (rs *ResultSet) Items() []Item {
func (rs *ResultSet) SetItems(items []Item) {
rs.items = items
rs.attributes = make([]ItemAttribute, len(items))
rs.mutex.Lock()
defer rs.mutex.Unlock()
rs.hasCachedMarkedItems = false
rs.cachedMarkedItems = nil
}
func (rs *ResultSet) SortCriteria() SortCriteria {
@ -67,24 +56,10 @@ func (rs *ResultSet) SortCriteria() SortCriteria {
func (rs *ResultSet) AddNewItem(item Item, attrs ItemAttribute) {
rs.items = append(rs.items, item)
rs.attributes = append(rs.attributes, attrs)
rs.mutex.Lock()
defer rs.mutex.Unlock()
rs.hasCachedMarkedItems = false
rs.cachedMarkedItems = nil
}
func (rs *ResultSet) SetMark(idx int, marked bool) {
rs.attributes[idx].Marked = marked
if !rs.hasCachedMarkedItems {
return
}
rs.mutex.Lock()
defer rs.mutex.Unlock()
rs.hasCachedMarkedItems = false
rs.cachedMarkedItems = nil
}
func (rs *ResultSet) SetHidden(idx int, hidden bool) {
@ -116,20 +91,12 @@ func (rs *ResultSet) IsNew(idx int) bool {
}
func (rs *ResultSet) MarkedItems() []ItemIndex {
rs.mutex.Lock()
defer rs.mutex.Unlock()
if rs.hasCachedMarkedItems {
return rs.cachedMarkedItems
}
items := make([]ItemIndex, 0)
for i, itemAttr := range rs.attributes {
if itemAttr.Marked && !itemAttr.Hidden {
items = append(items, ItemIndex{Index: i, Item: rs.items[i]})
}
}
rs.cachedMarkedItems = items
rs.hasCachedMarkedItems = true
return items
}

View file

@ -1,183 +0,0 @@
package models
import (
"testing"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/stretchr/testify/assert"
)
func TestMarkedItems(t *testing.T) {
t.Run("SetMark properly reflected in MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Initially, no items should be marked
assert.Len(t, rs.MarkedItems(), 0)
// Mark the first item
rs.SetMark(0, true)
markedItems := rs.MarkedItems()
assert.Len(t, markedItems, 1)
assert.Equal(t, 0, markedItems[0].Index)
// Mark the third item
rs.SetMark(2, true)
markedItems = rs.MarkedItems()
assert.Len(t, markedItems, 2)
assert.Equal(t, 0, markedItems[0].Index)
assert.Equal(t, 2, markedItems[1].Index)
// Verify the items themselves are correct
item1, ok1 := markedItems[0].Item.AttributeValueAsString("id")
item2, ok2 := markedItems[1].Item.AttributeValueAsString("id")
assert.True(t, ok1)
assert.True(t, ok2)
assert.Equal(t, "item1", item1)
assert.Equal(t, "item3", item2)
})
t.Run("item with Marked=true is in MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Directly set the Marked attribute to true for item at index 1
rs.SetMark(1, true)
markedItems := rs.MarkedItems()
assert.Len(t, markedItems, 1)
assert.Equal(t, 1, markedItems[0].Index)
item, ok := markedItems[0].Item.AttributeValueAsString("id")
assert.True(t, ok)
assert.Equal(t, "item2", item)
})
t.Run("adding marked items affects result of MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
assert.Len(t, rs.MarkedItems(), 2)
markedItems := rs.MarkedItems()
expectedIndices := []int{0, 1}
for i, expected := range expectedIndices {
assert.Equal(t, expected, markedItems[i].Index)
}
// Add a new unmarked item
rs.AddNewItem(Item{"id": &types.AttributeValueMemberS{Value: "item4"}}, ItemAttribute{})
assert.Len(t, rs.MarkedItems(), 2)
// Add a new marked item
rs.AddNewItem(Item{"id": &types.AttributeValueMemberS{Value: "item5"}}, ItemAttribute{Marked: true})
assert.Len(t, rs.MarkedItems(), 3)
markedItems = rs.MarkedItems()
expectedIndices = []int{0, 1, 4}
for i, expected := range expectedIndices {
assert.Equal(t, expected, markedItems[i].Index)
}
})
t.Run("changing SetMark updates length of MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
{"id": &types.AttributeValueMemberS{Value: "item4"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
rs.SetMark(2, true)
rs.SetMark(3, true)
assert.Len(t, rs.MarkedItems(), 4)
// Unmark one item
rs.SetMark(1, false)
assert.Len(t, rs.MarkedItems(), 3)
// Verify the correct items are marked
markedItems := rs.MarkedItems()
expectedIndices := []int{0, 2, 3}
for i, expected := range expectedIndices {
assert.Equal(t, expected, markedItems[i].Index)
}
// Unmark all remaining items
rs.SetMark(0, false)
rs.SetMark(2, false)
rs.SetMark(3, false)
assert.Len(t, rs.MarkedItems(), 0)
})
t.Run("changing items clears all marked items", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
rs.SetMark(2, true)
assert.Len(t, rs.MarkedItems(), 3)
// Call SetItems with new items
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "newitem1"}},
{"id": &types.AttributeValueMemberS{Value: "newitem2"}},
})
// All marks should be cleared
assert.Len(t, rs.MarkedItems(), 0)
// Verify none of the new items are marked
assert.False(t, rs.Marked(0))
assert.False(t, rs.Marked(1))
})
t.Run("hidden items are excluded from MarkedItems", func(t *testing.T) {
rs := &ResultSet{}
rs.SetItems([]Item{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
{"id": &types.AttributeValueMemberS{Value: "item3"}},
})
// Mark all items
rs.SetMark(0, true)
rs.SetMark(1, true)
rs.SetMark(2, true)
// Hide the second item
rs.SetHidden(1, true)
markedItems := rs.MarkedItems()
assert.Len(t, markedItems, 2)
// Verify only items 0 and 2 are in the marked items
assert.Equal(t, 0, markedItems[0].Index)
assert.Equal(t, 2, markedItems[1].Index)
})
}

View file

@ -10,8 +10,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/stretchr/testify/assert"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"github.com/stretchr/testify/assert"
)
func TestModExpr_Query(t *testing.T) {
@ -502,9 +502,6 @@ func TestQueryExpr_EvalItem(t *testing.T) {
{expr: `alpha^="al"`, expected: &types.AttributeValueMemberBOOL{Value: true}},
{expr: `alpha="foobar"`, expected: &types.AttributeValueMemberBOOL{Value: false}},
{expr: `alpha^="need-something"`, expected: &types.AttributeValueMemberBOOL{Value: false}},
{expr: `""=""`, expected: &types.AttributeValueMemberBOOL{Value: true}},
{expr: `"abc"="abc"`, expected: &types.AttributeValueMemberBOOL{Value: true}},
{expr: `""="abc"`, expected: &types.AttributeValueMemberBOOL{Value: false}},
// Comparison
{expr: "three > 4", expected: &types.AttributeValueMemberBOOL{Value: false}},

View file

@ -2,30 +2,18 @@ package itemrenderer
import (
"fmt"
"io"
"text/tabwriter"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models/itemrender"
"io"
"text/tabwriter"
)
type Service struct {
annotation Annotation
styles styleRenderer
styles styleRenderer
}
func NewService(
fileTypeStyle StyleRenderer,
metaInfoStyle StyleRenderer,
) *Service {
if fileTypeStyle == nil {
fileTypeStyle = plainTextStyleRenderer{}
}
if metaInfoStyle == nil {
metaInfoStyle = plainTextStyleRenderer{}
}
func NewService(fileTypeStyle StyleRenderer, metaInfoStyle StyleRenderer) *Service {
return &Service{
annotation: nil,
styles: styleRenderer{
fileTypeRenderer: fileTypeStyle,
metaInfoRenderer: metaInfoStyle,
@ -33,10 +21,6 @@ func NewService(
}
}
func (s *Service) SetAnnotation(a Annotation) {
s.annotation = a
}
func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.ResultSet, plainText bool) {
styles := s.styles
if plainText {
@ -49,47 +33,25 @@ func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.Re
for _, colName := range resultSet.Columns() {
seenColumns[colName] = struct{}{}
if r := itemrender.ToRenderer(item[colName]); r != nil {
p := models.AttrPathNode{Key: colName}
s.renderItem(tabWriter, resultSet, item, p, "", r, styles)
s.renderItem(tabWriter, "", colName, r, styles)
}
}
for k, _ := range item {
if _, seen := seenColumns[k]; !seen {
if r := itemrender.ToRenderer(item[k]); r != nil {
p := models.AttrPathNode{Key: k}
s.renderItem(tabWriter, resultSet, item, p, "", r, styles)
s.renderItem(tabWriter, "", k, r, styles)
}
}
}
tabWriter.Flush()
}
func (m *Service) renderItem(
w io.Writer,
resultSet *models.ResultSet,
item models.Item,
path models.AttrPathNode,
prefix string,
r itemrender.Renderer,
sr styleRenderer,
) {
fmt.Fprint(w, prefix)
fmt.Fprint(w, path.Key)
fmt.Fprint(w, "\t")
fmt.Fprint(w, sr.fileTypeRenderer.Render(r.TypeName()))
fmt.Fprint(w, "\t")
fmt.Fprint(w, r.StringValue())
fmt.Fprint(w, sr.metaInfoRenderer.Render(r.MetaInfo()))
if m.annotation != nil {
fmt.Fprint(w, " ")
fmt.Fprint(w, sr.metaInfoRenderer.Render(m.annotation.AnnotateAttribute(resultSet, item, path)))
}
fmt.Fprint(w, "\n")
func (m *Service) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer, sr styleRenderer) {
fmt.Fprintf(w, "%s%v\t%s\t%s%s\n",
prefix, name, sr.fileTypeRenderer.Render(r.TypeName()), r.StringValue(), sr.metaInfoRenderer.Render(r.MetaInfo()))
if subitems := r.SubItems(); len(subitems) > 0 {
for _, si := range subitems {
p := models.AttrPathNode{Key: si.Key, Parent: &path}
m.renderItem(w, resultSet, item, p, prefix+" ", si.Value, sr)
m.renderItem(w, prefix+" ", si.Key, si.Value, sr)
}
}
}
@ -98,13 +60,3 @@ type styleRenderer struct {
fileTypeRenderer StyleRenderer
metaInfoRenderer StyleRenderer
}
type Annotation interface {
AnnotateAttribute(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string
}
type AnnotationFunc func(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string
func (af AnnotationFunc) AnnotateAttribute(rs *models.ResultSet, item models.Item, path models.AttrPathNode) string {
return af(rs, item, path)
}

View file

@ -26,6 +26,7 @@ func Default() *KeyBindings {
},
View: &ViewKeyBindings{
Mark: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "mark")),
ToggleMarkedItems: key.NewBinding(key.WithKeys("M"), key.WithHelp("M", "toggle marged items")),
CopyItemToClipboard: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy item to clipboard")),
CopyTableToClipboard: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "copy table to clipboard")),
Rescan: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "rescan")),

View file

@ -32,6 +32,7 @@ type TableKeyBinding struct {
type ViewKeyBindings struct {
Mark key.Binding `keymap:"mark"`
ToggleMarkedItems key.Binding `keymap:"toggle-marked-items"`
CopyItemToClipboard key.Binding `keymap:"copy-item-to-clipboard"`
CopyTableToClipboard key.Binding `keymap:"copy-table-to-clipboard"`
Rescan key.Binding `keymap:"rescan"`

View file

@ -1,11 +1,8 @@
package ui
import (
"log"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
bus "github.com/lmika/events"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/commandctrl"
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/controllers"
@ -23,6 +20,8 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
bus "github.com/lmika/events"
"log"
)
const (
@ -126,6 +125,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, events.SetTeaMessage(m.tableWriteController.ToggleMark(idx))
}
case key.Matches(msg, m.keyMap.ToggleMarkedItems):
return m, events.SetTeaMessage(m.tableReadController.Mark(controllers.MarkOpToggle, ""))
case key.Matches(msg, m.keyMap.CopyItemToClipboard):
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, events.SetTeaMessage(m.tableReadController.CopyItemToClipboard(idx))

View file

@ -1,8 +1,6 @@
package dynamoitemview
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@ -11,6 +9,7 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/frame"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/styles"
"strings"
)
type Model struct {

View file

@ -1,8 +1,6 @@
package statusandprompt
import (
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
@ -11,6 +9,7 @@ import (
"lmika.dev/cmd/dynamo-browse/internal/common/ui/events"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
"strings"
)
// StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt
@ -25,7 +24,6 @@ type StatusAndPrompt struct {
spinner spinner.Model
spinnerVisible bool
pendingInput *pendingInputState
pendingKeyState *pendingKeyState
textInput textinput.Model
width, height int
lastModeLineHeight int
@ -87,7 +85,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
s.statusMessage = msg.StatusMessage()
case events.PromptForInputMsg:
if s.pendingInput != nil || s.pendingKeyState != nil {
if s.pendingInput != nil {
// ignore, already in an input
return s, nil
}
@ -96,40 +94,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.textInput.Focus()
s.textInput.SetValue("")
s.pendingInput = newPendingInputState(msg)
case events.PromptForKeyMsg:
if s.pendingInput != nil || s.pendingKeyState != nil {
// ignore, already in an input
return s, nil
}
s.statusMessage = msg.Prompt
s.pendingKeyState = &pendingKeyState{msg}
case tea.KeyMsg:
if s.pendingKeyState != nil {
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
if s.pendingKeyState.originalMsg.OnCancel != nil {
pendingKeyState := s.pendingKeyState
cc.Add(func() tea.Msg {
m := pendingKeyState.originalMsg.OnCancel()
return m
})
}
s.pendingKeyState = nil
default:
if s.pendingKeyState.originalMsg.OnDone != nil {
pendingKeyState := s.pendingKeyState
cc.Add(func() tea.Msg {
m := pendingKeyState.originalMsg.OnDone(msg.String())
return m
})
}
s.pendingKeyState = nil
}
s.statusMessage = ""
return s, cc.Cmd()
}
if s.pendingInput != nil {
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
@ -222,7 +187,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (s *StatusAndPrompt) InPrompt() bool {
return s.pendingInput != nil || s.pendingKeyState != nil
return s.pendingInput != nil
}
func (s *StatusAndPrompt) View() string {

View file

@ -14,7 +14,3 @@ func newPendingInputState(msg events.PromptForInputMsg) *pendingInputState {
type PasteboardProvider interface {
ReadText() (string, bool)
}
type pendingKeyState struct {
originalMsg events.PromptForKeyMsg
}

View file

@ -20,7 +20,7 @@ type ItemViewStyle struct {
var DefaultStyles = Styles{
ItemView: ItemViewStyle{
FieldType: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#2B800C", Dark: "#73C653"}),
MetaInfo: lipgloss.NewStyle().Foreground(lipgloss.Color("#707070")),
MetaInfo: lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")),
},
Frames: frame.Style{
ActiveTitle: lipgloss.NewStyle().

37
linux.goreleaser.yml Normal file
View file

@ -0,0 +1,37 @@
builds:
- id: dynamo-browse
targets:
- windows_amd64
- linux_amd64
env:
- CGO_ENABLED=1
main: ./cmd/dynamo-browse/.
binary: dynamo-browse
archives:
- id: zip
builds:
- dynamo-browse
wrap_in_directory: true
format_overrides:
- goos: windows
format: zip
- goos: linux
format: tar.gz
nfpms:
- id: package_nfpms
package_name: audax
builds:
- dynamo-browse
vendor: lmika
homepage: https://audax.tools/
maintainer: Leon Mika <lmika@lmika.org>
description: TUI tools for AWS administration
license: MIT
formats:
- deb
- rpm
bindir: /usr/local/bin
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"

31
macos.goreleaser.yml Normal file
View file

@ -0,0 +1,31 @@
builds:
- id: dynamo-browse
targets:
- darwin_amd64
- darwin_arm64
env:
- CGO_ENABLED=1
main: ./cmd/dynamo-browse/.
binary: dynamo-browse
archives:
- id: zip
builds:
- dynamo-browse
wrap_in_directory: true
format_overrides:
- goos: macos
format: tar.gz
brews:
- name: audax
repository:
owner: lmika
name: homebrew-audax
token: "{{ .Env.HOMEBREW_GITHUB_TOKEN }}"
folder: Formula
homepage: https://dynamobrowse.app/
description: TUI tools for AWS administration
license: MIT
checksum:
name_template: 'checksums-macos.txt'
snapshot:
name_template: "{{ .Tag }}-next"

View file

@ -4,17 +4,17 @@ import (
"context"
"flag"
"fmt"
"log"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/brianvoe/gofakeit/v6"
"github.com/pkg/errors"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/models"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/providers/dynamo"
"lmika.dev/cmd/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/lmika/gopkgs/cli"
"github.com/pkg/errors"
"log"
)
func main() {
@ -28,7 +28,7 @@ func main() {
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatalf("cannot load AWS config: %v", err)
cli.Fatalf("cannot load AWS config: %v", err)
}
dynamoClient := dynamodb.NewFromConfig(cfg,