Skip to content

Commit

Permalink
Zui with No Tabs Open (#2797)
Browse files Browse the repository at this point in the history
In order to achieve this, we had to make these changes.

Remove the lake from the URL
The "url" for each tab was previously prefixed with /lake/:id. This had to be removed, and a migration created to migrate all the urls saved in the state. The routes have been updated to reflect this as well.

Store the current lake id in the state for each window.
A window will only be able to connect to one lake at a time. This is like how slack and vscode have designed their apps. Their "workspaces" are the equivalent of our "lakes". You can have multiple windows connected to different lakes though. Wrote migration to set the lake id to whatever the lake id in the last opened tab.

Remove the constraint of having 1 tab always open.

Create a view for when there are no tabs open. Right now it's just the big zed logo background image.

Assign the tabs to a lake. When switching lakes, the tabs for the previous lake will be saved and hidden. When switching back, they will be restored.

Extras:

I made the width and presence of the detail pane the same for every tab in a window. This keeps it from jumping around as you move through the tabs. Feels much better.
I styled the tabs slightly different.
  • Loading branch information
jameskerr authored Jul 17, 2023
1 parent f20ba93 commit 05f017b
Show file tree
Hide file tree
Showing 124 changed files with 1,049 additions and 978 deletions.
44 changes: 42 additions & 2 deletions CODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,33 @@

This is here to document the design patterns chosen by the developers. It documents structures, abstractions, and philosophy in this repo.

## Dependencies

**All dependencies should be installed as development.** This app is unlike traditional web or node apps. All dependencies should be development dependencies unless esbuild cannot bundle it. The reason: before we package the app, we bundle all the code into a single file. The bundled JavaScript has no need to look into node_modules because all dependencies are already included in the bundle. When we package the app, it will include all the production dependencies in the app package. So, since we bundle most ourselves, there's no need to have duplicate packages in the packaged app's node_modules.

## FAQs

How do I add a main process initializer?
### How do I add a main process initializer?

1. Create a new file in `src/electron/initializers/`.
2. Add the following line to `src/electron/initializers/index.ts`.
2. Export a function called _initialize_(main) that takes the main object as its only argument.
3. Add the following line to `src/electron/initializers/index.ts`.

```
export * as myNewInitializer from "./my-new-initializer"
```

Export all symbols as a camel cased alias of the file name. This will now run automatically when the app starts.

### How do I write a state migration?

1. Run `bin/gen migration my_migration_name`.
2. Edit the files it produced to perform your migration.
3. Use the getAllTabs and getAllStates helpers as needed.
4. Remember that the states are either the main process state or the window states.
5. Add that file to the src/js/state/migrations/index.ts following the pattern there.
6. Create a sample state, if needed, by running the app at the latest released version, getting it into the state you want, then copying run/appState.json into src/test/unit/states/v0.0.0.json using the version as the file name.

## Folders

Documentation for where code should go.
Expand Down Expand Up @@ -66,3 +80,29 @@ The plugin api runs in the Node main process and is given to plugin authors to e
_Main Process Initializers_

Code that needs to be run one time before the app starts up can be put in an initializer. An initializer is a file that lives in the folder `src/electron/initializers/`. It must export a function named _initialize(main)_ that takes the Main Object as its only argument. See the FAQ for an example of creating a new initializer.

_Query_

A query in the app is like a container object. It holds the name and id of the query. It does not contain the zed code. Those are stored in a QueryVersion. Each Query has many QueryVersions, showing the history of that query.

_Session Query_

A session query is like an unnamed Query. Each session (tab) has exactly one SessionQuery associated with it. The SessionQuery has many QueryVersions associated with it.

_Store_

The store contains the state for the whole application. Parts of the store apply to the whole app (main process and all windows), like the list of lakes, the list of the pools, the list of queries, and the configurations. Then state only relevant to one window, then state that's only relative to the tab.

**State Hierarchy**

1. Application Level
2. Window Level
3. Tab Level

Application level state has a `$` prefix to the action names. Actions dispatched with the `$` prefix get dispatched to the main process and all windows.

Window level state is everything that's not in the `tabReducer`, but doesn't have a `$` prefix.

Tabs state is found in the `tabReducer` function.

The tabs are grouped by lakeId within the window state. Each window has a different group of tabs per lakeId. When a user switches lakes, the tabs from the previous lake will be hidden and the tabs from the current lake shown. When switching back, the old tabs will be restored.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "Zed User Interface",
"repository": "https://github.com/brimdata/zui",
"license": "BSD-3-Clause",
"version": "1.0.1",
"version": "1.1.0",
"main": "dist/main.js",
"author": "Brim Data <support@brimdata.io> (http://www.brimdata.io)",
"workspaces": [
Expand Down Expand Up @@ -83,8 +83,8 @@
"@types/sprintf-js": "^1.1.2",
"@types/styled-components": "^5.1.3",
"@types/tmp": "^0.2.0",
"@typescript-eslint/eslint-plugin": "5.60.1",
"@typescript-eslint/parser": "5.60.1",
"@typescript-eslint/eslint-plugin": "6.0.0",
"@typescript-eslint/parser": "6.0.0",
"abort-controller": "^3.0.0",
"acorn": "^7.4.1",
"ajv": "^6.9.1",
Expand All @@ -111,7 +111,7 @@
"esbuild": "^0.17.18",
"eslint": "^8.11.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^26.1.2",
"eslint-plugin-jest": "^27.2.3",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"event-source-polyfill": "^1.0.25",
Expand Down
2 changes: 2 additions & 0 deletions pages/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {Modals} from "src/js/components/Modals"
import Tooltip from "src/js/components/Tooltip"
import initialize from "src/js/initializers/initialize"
import TabHistories from "src/js/state/TabHistories"
import Tabs from "src/js/state/Tabs"
import {getPersistedWindowState} from "src/js/state/stores/get-persistable"

export default function DetailPage() {
const [app, setApp] = useState(null)

useEffect(() => {
initialize().then((vars) => {
vars.store.dispatch(Tabs.create()) // Make a "tab" so that selectors work
window.onbeforeunload = () => {
vars.api.abortables.abortAll()
vars.store.dispatch(TabHistories.save(global.tabHistories.serialize()))
Expand Down
4 changes: 2 additions & 2 deletions src/app/commands/new-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import Tabs from "src/js/state/Tabs"
import {newPoolPath} from "../router/utils/paths"
import {createCommand} from "./command"

export const newPool = createCommand({id: "newPool"}, ({api, dispatch}) => {
dispatch(Tabs.activateUrl(newPoolPath(api.current.lakeId)))
export const newPool = createCommand({id: "newPool"}, ({dispatch}) => {
dispatch(Tabs.activateUrl(newPoolPath()))
})
11 changes: 8 additions & 3 deletions src/app/commands/pins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import submitSearch from "../query-home/flows/submit-search"
import {createCommand} from "./command"
import Current from "src/js/state/Current"
import PoolSettings from "src/js/state/PoolSettings"
import Tabs from "src/js/state/Tabs"

export const createFromEditor = createCommand(
"pins.createFromEditor",
Expand Down Expand Up @@ -40,9 +41,13 @@ export const createFrom = createCommand<[value?: string]>(

export const updateFrom = createCommand(
"pins.updateFrom",
({dispatch}, value: string) => {
dispatch(Editor.setFrom(value))
dispatch(submitSearch())
({dispatch, getState, api}, value: string) => {
if (Tabs.none(getState())) {
api.queries.open({pins: [{type: "from", value}], value: ""})
} else {
dispatch(Editor.setFrom(value))
dispatch(submitSearch())
}
}
)

Expand Down
11 changes: 4 additions & 7 deletions src/app/commands/pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import errors from "src/js/errors"
import {ErrorData} from "src/js/errors/types"
import ErrorFactory from "src/js/models/ErrorFactory"
import {PoolName} from "../features/sidebar/pools-section/pool-name"
import {lakePoolPath} from "../router/utils/paths"
import {createCommand} from "./command"
import {deletePools} from "./delete-pools"
import {invoke} from "src/core/invoke"
Expand Down Expand Up @@ -61,13 +60,13 @@ export const rename = createCommand(
export const deleteGroup = createCommand(
"pools.deleteGroup",
({api}, group: string[]) => {
const decendentIds = api.pools.all
const descendantIds = api.pools.all
.filter((pool) => {
return new PoolName(pool.name, api.pools.nameDelimiter).isIn(group)
})
.map((pool) => pool.id)

return deletePools.run(decendentIds)
return deletePools.run(descendantIds)
}
)

Expand All @@ -79,8 +78,6 @@ export const createAndLoadFiles = createCommand(
opts: {name?: string; format?: LoadFormat} & Partial<CreatePoolOpts> = {}
) => {
let poolId: string | null = null
const lakeId = api.current.lakeId
const tabId = api.current.tabId
const poolNames = api.pools.all.map((p) => p.name)
if (!opts.name && files.length === 0) {
api.toast("No pool name and no files provided.")
Expand All @@ -101,14 +98,14 @@ export const createAndLoadFiles = createCommand(
error: "Load error",
})
await promise
return poolId
}

api.url.push(lakePoolPath(poolId, lakeId), {tabId})
} catch (e) {
console.error(e)
if (poolId) await api.pools.delete(poolId)
api.notice.error(parseError(e))
api.pools.syncAll()
throw e
}
}
)
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/models/zed-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class ZedAst {
if (!from) return null
const trunk = from.trunks.find((t) => t.source.kind === "Pool")
if (!trunk) return null
const name = trunk.source.spec.pool.text
const name = trunk.source.spec.pool?.text
if (!name) return null
return name
}
Expand Down
11 changes: 2 additions & 9 deletions src/app/detail/NoSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import React from "react"
import {EmptyText} from "../features/right-pane/common"

const NoSelection = () => (
<div className="empty-message-wrapper">
<div className="empty-message">
<h3>No Log Selected</h3>
<p>Click a log line to view details.</p>
<p>
Toggle this pane with <code>Cmd + ]</code>.
</p>
</div>
</div>
<EmptyText>Select a value in the results to view details.</EmptyText>
)

export default NoSelection
6 changes: 5 additions & 1 deletion src/app/features/right-pane/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export const PaneHeader = styled.header`

export const EmptyText = styled.p`
padding: 24px;
margin-top: 33%;
opacity: 0.5;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
`
2 changes: 1 addition & 1 deletion src/app/features/right-pane/history/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const Circle = styled.div<{color: string}>`
const Line = styled.div`
width: 2px;
flex: 1;
background: #f6f6f7;
background: var(--border-color);
&:first-child {
border-radius: 0 0 1px 1px;
Expand Down
19 changes: 10 additions & 9 deletions src/app/features/right-pane/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import {HistorySection} from "./history/section"
import {SectionTabs} from "src/components/section-tabs"
import {PaneName} from "src/js/state/Layout/types"
import {ColumnsPane} from "src/panes/columns-pane/columns-pane"
import Appearance from "src/js/state/Appearance"

const Pane = styled(DraggablePane)`
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-color);
background: white;
background: var(--chrome-color);
`

const PaneContentSwitch = ({paneName}) => {
Expand All @@ -43,19 +44,19 @@ const BG = styled.div`
padding: 0 8px;
`

export function Menu() {
export function Menu(props: {paneName: string}) {
const dispatch = useDispatch()
const currentPaneName = useSelector(Layout.getCurrentPaneName)

const onChange = (name: string) => {
if (name === currentPaneName) return
if (name === props.paneName) return
dispatch(Layout.setCurrentPaneName(name as PaneName))
}

function makeOption(label: string, value: string) {
return {
label,
click: () => onChange(value),
checked: currentPaneName === value,
checked: props.paneName === value,
}
}

Expand All @@ -74,14 +75,14 @@ export function Menu() {
}

function Container({children}) {
const width = useSelector(Layout.getDetailPaneWidth)
const dispatch = useDispatch()
const isOpen = useSelector(Layout.getDetailPaneIsOpen)
const width = useSelector(Appearance.secondarySidebarWidth)
const isOpen = useSelector(Appearance.secondarySidebarIsOpen)

const onDrag = (e: React.MouseEvent) => {
const width = window.innerWidth - e.clientX
const max = window.innerWidth
dispatch(Layout.setDetailPaneWidth(Math.min(width, max)))
dispatch(Appearance.resizeSecondarySidebar(Math.min(width, max)))
}

if (!isOpen) return null
Expand All @@ -99,7 +100,7 @@ const RightPane = () => {

return (
<Container>
<Menu />
<Menu paneName={currentPaneName} />
<AppErrorBoundary>
<PaneContentSwitch paneName={currentPaneName} />
</AppErrorBoundary>
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/right-pane/versions-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ beforeEach(async () => {
system.store.dispatch(Queries.addItem({id: testQueryId, name: "test query"}))
system.store.dispatch(QueryVersions.at(testQueryId).create(testVersion1))
system.store.dispatch(QueryVersions.at(testQueryId).create(testVersion2))
system.navTo(lakeQueryPath(testQueryId, "testLakeId", testVersion2.version))
system.navTo(lakeQueryPath(testQueryId, testVersion2.version))
system.render(<VersionsSection />)
await screen.findAllByText(/test value/i)
})
Expand Down
3 changes: 1 addition & 2 deletions src/app/features/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ const PaneSwitch = ({name}) => {

const Pane = styled(DraggablePane)`
height: 100%;
width: 100%;
background: var(--sidebar-background);
overflow-x: unset;
grid-area: sidebar;
border-right: 1px solid var(--border-color-dark);
display: flex;
flex-direction: column;
`
Expand All @@ -58,7 +58,6 @@ export function Sidebar() {
const isOpen = useSelector(Appearance.sidebarIsOpen)
const currentSectionName = useSelector(Appearance.getCurrentSectionName)
const l = useSelector(Current.getLake)

const id = get(l, ["id"], "")
function onDragPane(e: MouseEvent) {
const width = e.clientX
Expand Down
5 changes: 2 additions & 3 deletions src/app/features/sidebar/lake-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import React from "react"
import useLakeId from "src/app/router/hooks/use-lake-id"
import tabHistory from "src/app/router/tab-history"
import {newPoolPath} from "src/app/router/utils/paths"
import {MenuItemConstructorOptions} from "electron"
import {useDispatch, useSelector} from "react-redux"
import styled from "styled-components"
Expand All @@ -13,6 +11,7 @@ import {AppDispatch} from "src/js/state/types"
import Lakes from "src/js/state/Lakes"
import {Lake} from "src/js/state/Lakes/types"
import lake from "src/js/models/lake"
import Window from "src/js/state/Window"

const LakeNameGroup = styled.div`
display: flex;
Expand Down Expand Up @@ -82,7 +81,7 @@ const showLakeSelectMenu = () => (dispatch, getState) => {
checked: isCurrent,
click: () => {
if (isCurrent) return
dispatch(tabHistory.push(newPoolPath(l.id)))
dispatch(Window.setLakeId(l.id))
},
})
})
Expand Down
14 changes: 8 additions & 6 deletions src/app/features/sidebar/pools-section/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ import {useFilesDrop} from "src/util/hooks/use-files-drop"
import {createAndLoadFiles} from "src/app/commands/pools"
import {useDispatch} from "src/app/core/state"
import Tabs from "src/js/state/Tabs"
import {useZuiApi} from "src/app/core/context"
import {lakePath} from "src/app/router/utils/paths"
import {lakePoolPath} from "src/app/router/utils/paths"

const PoolsSection = () => {
const dispatch = useDispatch()
const api = useZuiApi()
const [{isOver}, drop] = useFilesDrop({
onDrop: (files) => {
dispatch(Tabs.activateUrl(lakePath(api.current.lakeId)))
createAndLoadFiles.run(files.map((f) => f.path))
onDrop: async (files) => {
try {
const poolId = await createAndLoadFiles.run(files.map((f) => f.path))
dispatch(Tabs.activateUrl(lakePoolPath(poolId)))
} catch (e) {
// Handled
}
},
})
const [searchTerm, setSearchTerm] = useState("")
Expand Down
Loading

0 comments on commit 05f017b

Please sign in to comment.