Compare commits

..

26 commits

Author SHA1 Message Date
Leon Mika 3321333065 Turned on build for all pushes
All checks were successful
ci / Build (push) Successful in 4m15s
2025-10-26 11:35:00 +11:00
Leon Mika b95c3eb040 Switched back to release and CI workflows
Some checks failed
ci / Build (push) Has been cancelled
ci / Build (pull_request) Successful in 4m26s
2025-10-26 11:33:19 +11:00
Leon Mika 55791627a6 Added ssh prefix
All checks were successful
ci / Site (push) Successful in 1m50s
ci / Release MacOS (push) Successful in 27s
ci / Build (push) Successful in 3m45s
2025-10-26 11:24:53 +11:00
Leon Mika c196699c80 Fixed use of private key
Some checks failed
ci / Release MacOS (push) Successful in 27s
ci / Site (push) Successful in 1m49s
ci / Build (push) Has been cancelled
2025-10-26 11:22:57 +11:00
Leon Mika 0c5f4bf8b0 Fixed incorrect use of git
All checks were successful
ci / Release MacOS (push) Successful in 27s
ci / Site (push) Successful in 1m56s
ci / Build (push) Successful in 3m50s
2025-10-26 11:17:51 +11:00
Leon Mika 22ab141c62 Switched back to Docker build
All checks were successful
ci / Release MacOS (push) Successful in 28s
ci / Site (push) Successful in 1m51s
ci / Build (push) Successful in 3m49s
2025-10-26 11:11:19 +11:00
Leon Mika fdfa096f47 Temp moved build to macos
Some checks failed
ci / Build (push) Failing after 1m26s
ci / Site (push) Successful in 2m4s
ci / Release MacOS (push) Successful in 54s
2025-10-26 11:03:07 +11:00
Leon Mika 528f330e03 Upgraded UCL to v0.1.1
Some checks failed
ci / Release MacOS (push) Failing after 28s
ci / Build (push) Successful in 3m53s
ci / Site (push) Successful in 6m21s
2025-10-26 10:55:10 +11:00
Leon Mika 5c7590b0be Removed gopkg
Some checks failed
ci / Release MacOS (push) Failing after 24s
ci / Build (push) Successful in 3m49s
ci / Site (push) Failing after 6m35s
2025-10-26 10:39:13 +11:00
Leon Mika d72aff9ce1 Turned off Github overrides
Some checks failed
ci / Release MacOS (push) Failing after 24s
ci / Build (push) Successful in 3m56s
ci / Site (push) Has been cancelled
2025-10-26 10:32:29 +11:00
Leon Mika 9bd573ee17 Added snapshot
Some checks failed
ci / Site (push) Failing after 19s
ci / Release MacOS (push) Failing after 31s
ci / Build (push) Has been cancelled
2025-10-26 10:29:33 +11:00
Leon Mika 1ac434fb6e Added Github token to env
Some checks failed
ci / Site (push) Waiting to run
ci / Release MacOS (push) Failing after 22s
ci / Build (push) Has been cancelled
2025-10-26 10:28:38 +11:00
Leon Mika 87749738d7 Re-enabled MacOS release
Some checks failed
ci / Site (push) Successful in 23s
ci / Build (push) Successful in 3m31s
ci / Release MacOS (push) Failing after 5m13s
2025-10-26 10:18:41 +11:00
Leon Mika 1c89b10bf6 Need extended version of Hugo
All checks were successful
ci / Site (push) Successful in 24s
ci / Build (push) Successful in 3m29s
2025-10-26 10:09:48 +11:00
Leon Mika c3a33211f4 No sudo
Some checks failed
ci / Site (push) Failing after 21s
ci / Build (push) Has been cancelled
2025-10-26 10:07:17 +11:00
Leon Mika c73d1deed3 Just download static binary
Some checks failed
ci / Site (push) Failing after 11s
ci / Build (push) Has been cancelled
2025-10-26 10:06:28 +11:00
Leon Mika 2804d4c3db Fix to the hugo install command
Some checks failed
ci / Build (push) Successful in 4m51s
ci / Site (push) Has been cancelled
2025-10-26 09:56:56 +11:00
Leon Mika b20f02b9e2 Enabled extensions to Hugo
Some checks failed
ci / Site (push) Failing after 11s
ci / Build (push) Successful in 3m52s
2025-10-26 09:52:49 +11:00
Leon Mika 76339b1015 Added npm install
Some checks failed
ci / Build (push) Successful in 4m55s
ci / Site (push) Failing after 5m11s
2025-10-26 09:45:30 +11:00
Leon Mika bfaedd0d5b Fixed to Hugo site
Some checks failed
ci / Build (push) Successful in 4m59s
ci / Site (push) Failing after 5m9s
2025-10-26 09:39:24 +11:00
Leon Mika 9b7f783414 Stupid AI hallucinated a Go install flag that doesnt exist
Some checks failed
ci / Site (push) Failing after 11s
ci / Build (push) Has been cancelled
2025-10-26 09:37:44 +11:00
Leon Mika 196b3f9493 Switching to just Go installing hugo
Some checks failed
ci / Site (push) Failing after 12s
ci / Build (push) Has been cancelled
2025-10-26 09:35:23 +11:00
Leon Mika 302f4d4206 Switched to Hugo version manager
Some checks failed
ci / Site (push) Failing after 1m51s
ci / build (push) Successful in 4m12s
2025-10-26 09:31:07 +11:00
Leon Mika a93ec08bc3 Using fill path for actions-hugo
Some checks failed
ci / Site (push) Failing after 4s
ci / build (push) Has been cancelled
2025-10-26 09:27:50 +11:00
Leon Mika 0c1b87f080 Enabled site steps
Some checks failed
ci / site (push) Failing after 4s
ci / build (push) Has been cancelled
2025-10-26 09:26:04 +11:00
Leon Mika c474b18232 Added site to this repo and Gitlab to build it 2025-10-26 09:25:36 +11:00
62 changed files with 460 additions and 1314 deletions

View file

@ -69,7 +69,7 @@ jobs:
cd _site
netlify deploy --dir docs --prod
'Release':
'Release MacOS':
needs: Build
runs-on: macos
steps:
@ -79,9 +79,6 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: 1.25
- name: Setup Dependencies
run: |
brew install gpg
- name: Configure
run: |
git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika"
@ -90,12 +87,30 @@ jobs:
go install github.com/goreleaser/goreleaser/v2@v2.12.7
- name: Release
run: |
goreleaser release -f goreleaser.yml --skip=validate --clean
goreleaser publish -f macos.goreleaser.yml --skip=validate --clean
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_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 }}
# 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 }}

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 +1,7 @@
---
weight: 10
---
+++
title = "Dynamo-Browse"
bookToc = false
+++
<div class="site-header">
<img src="/images/dynamo-browse/dynamo-browse-logo.png">
@ -16,31 +17,15 @@ 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
Instructions for installing Dynamo-Browse can be found on the [Downloads page](/download).
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

@ -0,0 +1,35 @@
# User Guide
## Table Of Contents
- [Launching and Quitting](/docs/launching)
- [Selecting a Table](/docs/launching#selecting-a-table)
- [Selecting a Workspace](/docs/launching#selecting-a-workspace)
- [Quitting](/docs/launching#quitting)
- [Getting Around](/docs/getting-around)
- [The Back-stack](/docs/getting-around#the-back-stack)
- [Adjusting The Layout](/docs/getting-around#adjusting-the-layout)
- [Adjusting The Displayed Columns](/docs/getting-around#adjusting-the-displayed-columns)
- [Entering Commands](/docs/getting-around#entering-commands)
- [Filtering and Querying](/docs/filtering-querying)
- [Filtering](/docs/filtering-querying#filtering)
- [Querying](/docs/filtering-querying#querying)
- [Editing Items](/docs/editing-items)
- [Marking Items](/docs/editing-items#marking-items)
- [Modifying Attributes](/docs/editing-items#modifying-attributes)
- [Deleting Attributes](/docs/editing-items#deleting-attributes)
- [Adding Items](/docs/editing-items#adding-items)
- [Deleting Items](/docs/editing-items#deleting-items)
- [Committing Changes](/docs/editing-items#committing-changes)
- [Backing Out of Changes](/docs/editing-items#backing-out-of-changes)
- [Customising Dynamo-Browse](/docs/customising)
- [The RC File](/docs/customising#rc-file)
- [Rebinding Keys](/docs/customising#rebinding-keys)
References
- [Key Bindings](/docs/reference/key-bindings)
- [Commands](/docs/reference/commands)
- [Query Expressions](/docs/reference/query-expressions)
- [Launch Flags](/docs/reference/launch-flags)
- [Settings](/docs/reference/settings)

View file

@ -1,24 +1,22 @@
---
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
tracked within the workspace file. So in order to keep customisations across relaunches, these commands
can be added to an RC file.
## RC Files
## The RC File
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:
The RC file is a text file containing commands that will be executed by Dynamo-Browse upon launch.
By default, the RC file is located at the following path:
```
$HOME/.config/dynamo-browse/init.ucl
$HOME/.config/audax/dynamo-browse/init.rc
```
Any number of RC files can be present in this directory, and they are executed in lexicographical order.
This file is primarily intended for commands that customise Dynamo-Browse in a particular way, but any
command can be entered here. If this file is found, Dynamo-Browse will invoke each command before loading
or prompting the table.
## Rebinding Keys
@ -40,11 +38,4 @@ At the moment each binding name can only be mapped to a single key. It's also c
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).
(check the "Show binding names" checkbox). Note that some bindings may not have default key bindings.

View file

@ -1,7 +1,3 @@
---
title: Editing Items
weight: 40
---
# Editing Items
Dynamo-Browse offers some basic facilities for editing items — such as creating items, deleting items,

View file

@ -1,8 +1,4 @@
---
title: Querying and Filtering Results
weight: 30
---
# Querying and Filtering Results
# Querying And Viewing Results
## Querying
@ -12,24 +8,7 @@ weight: 30
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
or scan, depending on the expression. 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.

View file

@ -1,7 +1,3 @@
---
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.

View file

@ -1,8 +1,4 @@
---
title: Launching and Quitting
weight: 10
---
# Launching and Quitting
# Launching And Quitting
To launch Dynamo-Browse, run the following command at the terminal:

View file

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

View file

@ -1,12 +1,5 @@
---
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
```
@ -47,7 +40,7 @@ Displays _message_ in the status bar. Mainly used for debugging.
## export
```
:export <filename> [-all]
:export [-all] <filename>
```
Writes the currently loaded items as a CSV file to _filename_.
@ -112,7 +105,7 @@ Quits Dynamo-Browse.
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
```
:set <name> [value]
@ -123,7 +116,7 @@ Set the value of a setting. Flag setting types can be enabled without any value
## set-attr
```
:set-attr <attributeName> [type]
:set-attr [type] <attributeName>
```
Alias: `sa`

View file

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

View file

@ -1,7 +1,3 @@
---
title: Key Bindings
weight: 10
---
# Key Bindings
<div data-controller="keybindings">

View file

@ -1,7 +1,3 @@
---
title: Launch Flags
weight: 40
---
# Launch Flags
## -debug

View file

@ -1,7 +1,3 @@
---
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>),

View file

@ -0,0 +1,5 @@
---
title: "Script API"
type: script-api
---
# Script API

View file

@ -1,7 +1,3 @@
---
title: Settings
weight: 50
---
# Settings
## default-limit

View file

@ -0,0 +1,103 @@
---
title: "Scripting"
---
# Scripting
Scripts can be used to automate certain tasks with Dynamo-Browse. They can also be used to define
new commands or key bindings.
## Scripting Basics
Dynamo-Browse scripts are written using the [Tamarin](https://cloudcmds.github.io/tamarin/) scripting language,
which looks a lot like [Go](https://go.dev). All features of the language are available in Dynamo-Browse.
The typical "hello world" script for Dynamo-Browse is below:
```
ui.print("Hello, world")
```
This uses the [ui](/docs/reference/script-api/#module-ui) package, which is the package used to interact with
the Dynamo-Browse user interface.
A full list of supported packages can be found in the [Script API](/docs/reference/script-api/) reference, along
with the builtins and packages supported by Tamarin itself.
{{<hint info>}}
**Note:** the [ext](/docs/reference/script-api/#module-ext) package is only available to Extension Scripts.
{{</hint>}}
To execute this script, use the `run-script` command:
```
run-script /path/to/script/hello.tm
```
You'll see that the message "Hello, world" will appear in the status bar of Dynamo-Browse.
<!-- TODO: Screenshot -->
Any `print` or `printf` messages will be written to the debug log with the prefix `script <filename>`. The
debug log is turned off by default, but it can be enabled using the [-debug](/docs/reference/launch-flags/#-debug) flag on launch.
Scripts loaded using the `run-script` command are for ad-hoc automation tasks that are not necessarily designed for
repeated use. These ad-hoc scripts are executed, then immediately unloaded, and are not generally allowed to extend
Dynamo-Browse. In order to do so, you will need to write an Extension Script.
## Extension Scripts
Extension scripts are scripts designed to extend Dynamo-Browse in some way, such as with new commands or key bindings.
They are traditionally loaded on startup and exist in the predefined "script" directory. They are usually designed for
repeated operations, including those that can be bound to command name or keys.
The following is an example script which will define a "goto" command. When invoked, the script will prompt the
user for the value of the partition key. It will then perform a query over the currently viewed table for any rows with
that partition key. If no error occurred, the results of the query will be shown to the user.
```
// Define a new "goto" command, which can be invoked when the user presses ':' and types in 'goto'
ext.command("goto", func() {
// Use the information of the current table to get the name of the partition key.
pkName := session.current_table().keys["partition"]
// Prompt the user for the value to go to. The user can press Esc, which will cancel
// the input and return 'nil'.
keyVal := ui.prompt(pkName + "? ")
if keyVal == nil {
return nil
}
// Run a query over the DynamoDB table for any rows with the partition key. Notice
// the use of the 'args' option, and the presence of both the name prefix (':key')
// and value prefix ('$val').
res := session.query(":key = $val", {
args: {
key: pkName,
val: keyVal,
},
})
// The query method will return either an error or a result. If it's an error, print
// a notice and exist.
if res.is_err() {
ui.print("Can't goto: " + res.err_msg())
return nil
}
// If no error, unwrap the result object to get the result-set returned from the query.
// Then change the current result-set to this one. This will change the result-set the
// user is currently seeing.
session.set_result_set(res.unwrap())
})
```
To load an extension script, use the `load-script` command:
```
load-script script.tm
```
The script must exist in the "script" directory, which by default is:
```
$HOME/.config/audax/dynamo-browse/scripts
```

View file

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

View file

@ -1,8 +1,19 @@
---
weight: 20
---
+++
layout = "single"
+++
# Download
Binary packages can be [download from GitHub](https://github.com/lmika/audax/releases/latest).
## MacOS Using Homebrew
If you have Homebrew, you can install using the following command:
```
brew tap lmika/audax
brew install audax
```
## Linux

View file

@ -0,0 +1,25 @@
+++
headless = true
+++
- [Download]({{< relref "/download" >}})
- [Releases](https://github.com/lmika/audax/releases)
- [Github](https://github.com/lmika/audax)
<br>
[**User Guide**]({{< relref "/docs" >}})
- [Launching]({{< relref "/docs/launching" >}})
- [Getting Around]({{< relref "/docs/getting-around" >}})
- [Filtering And Querying]({{< relref "/docs/filtering-querying" >}})
- [Editing Items]({{< relref "/docs/editing-items" >}})
- [Customising]({{< relref "/docs/customising" >}})
- [Scripting]({{< relref "/docs/scripting" >}})
<br>
**References**
- [Key Bindings]({{< relref "/docs/reference/key-bindings" >}})
- [Commands]({{< relref "/docs/reference/commands" >}})
- [Query Expressions]({{< relref "/docs/reference/query-expressions" >}})
- [Launch Flags]({{< relref "/docs/reference/launch-flags" >}})
- [Settings]({{< relref "/docs/reference/settings" >}})
- [Script API]({{< relref "/docs/reference/script-api" >}})
<br>

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

@ -0,0 +1,75 @@
module: ext
docs: |
Provides access to the extension points scripts can used to extend the functionality of Dynamo-Browse.
This module is only available for scripts loaded using the [load-script]() command.
symbols:
- name: command
syntax: ext.command(name, fn)
docs: |
Defines a new command, which can be invoked by entering _name_ within the main view mode.
The parameter _fn_ must be a function, which will be executed when the _name_ command is entered
while in view mode.
The command can accept arguments, which will be passed in to the parameters of _fn_. The number
of command arguments must match the number of parameters, except for any function arguments with
a default value.
example: |
ext.command("add", func(x, y) {
sum := x + y
ui.print("x + y = ", sum)
})
- name: related_items
syntax: ext.related_items(table, fn)
docs: |
Defines a "related item" for a table. These act as quick jumps between tables.
When the user presses Shift+O, all the related item functions that match the given
table will be evaluated. Each one is to return zero or more related queries, which are presented
to the user as a list. When the user selects one, the query will be evaluated and the result set will
be shown.
The _table_ parameter is the name of the table of the related items managed by this function.
If the last character of the table is `*`, then _table_ will be treated as a name prefix.
The _fn_ will produce a list of queries that are related to a given item. The function takes the currently
selected item as the argument, and is expected to produce a list of maps, with each map having the following
fields:
- `label`: The label to use for the picker option
- `query`: The query expression that will run when the option is chosen
- `table`: The table to run the query over. If not set, the current table will be used
- `args`: A map of query placeholder values
- `on_select`: An optional function that will run in place of a predefined query. If set, the `query` field will
be ignored.
example: |
ext.related_items("user-account", func(item) {
return [
{
"label": "Customer",
"table": "billing",
"query": "email=$email",
"args": {"email": item.attr("email")},
},
]
})
- name: key_binding
syntax: ext.key_binding(name, options, fn)
docs: |
Defines a new key binding, which can be invoked while viewing the table.
The _name_ parameter defines the binding name. The binding names will be prefixed with
`ext.<script_basename>`. This name can be used with the [rebind]() command.
The _option_ parameter defines a map of options. The only valid option is
`default`, which is the default key to use for this binding. If unset, the binding will
have no key binding and can only be bound using the [rebind]() command.
The _fn_ parameter is the function that will be invoked when the key is pressed.
It must accept no parameters.
example: |
// Script name: sayhello.tm
//
// This binding can be rebound with the command "rebind ext.sayhello.hello <key>"
ext.key_binding("hello", {"default": "H"}, func() {
ui.print("Hello")
})

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

@ -3,25 +3,18 @@ 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.
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. A negative index can be used to retrieve an
item from the last 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'
- name: length
syntax: resultset.length
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'
Returns the number of items within the result set.
- 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

@ -177,7 +177,6 @@ func main() {
keyBindingController,
pasteboardProvider,
settingsController,
itemRendererService,
)
commandController, err := commandctrl.NewCommandController(inputHistoryService, stdCommands)

8
go.mod
View file

@ -25,10 +25,10 @@ require (
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.1
)
require (
@ -50,12 +50,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 +63,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

12
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=
@ -152,8 +146,6 @@ 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=
@ -162,8 +154,6 @@ github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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=
@ -259,5 +249,3 @@ 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=

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,11 @@ 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 +16,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) {
@ -175,7 +171,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 +191,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 +209,14 @@ 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,
"prompt-keypress": m.uiInKey,
"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

@ -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

@ -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://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
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"

36
macos.goreleaser.yml Normal file
View file

@ -0,0 +1,36 @@
version: 2
builds:
- id: dynamo-browse
targets:
- darwin_amd64
- darwin_arm64
env:
- CGO_ENABLED=1
main: ./cmd/dynamo-browse/.
binary: dynamo-browse
archives:
- id: zip
wrap_in_directory: true
formats:
- tar.gz
homebrew_casks:
- name: dynamo-browse
repository:
owner: casks
name: dynamo-browse
git:
url: 'ssh://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
checksum:
name_template: 'checksums-macos.txt'
snapshot:
version_template: "{{ .Tag }}-next"