diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84ebd68..53384b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,3 +33,5 @@ jobs: - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} diff --git a/README.md b/README.md index e84c8bd..60c03ae 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A companion app written with **React** and **Tauri** to help you manage your app - [x] Light/dark theme - [ ] View the logs of your builds - [ ] View all the available builds -- [ ] Edit / remove a bundled app +- [x] Edit / remove a bundled app ## How to use it ? diff --git a/package.json b/package.json index 3432414..e013cce 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "tauri-appcenter-companion", "private": false, "repository": "https://github.com/zenoxs/tauri-appcenter-companion", - "version": "0.3.6", + "version": "0.4.0", "license": "GPL-3.0", "author": "Amaury CIVIER", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint src --ext .ts,.tsx,.js,.jsx", + "lint": "eslint src --ext .ts,.tsx,.js,.jsx --max-warnings=0", "tauri": "tauri", "tauri:dev": "tauri dev", "postinstall": "cd node_modules/tauri-plugin-websocket && yarn && yarn build" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2bf0fdd..4373d2e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1620,6 +1620,16 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" +[[package]] +name = "open" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0524af9508f9b5c4eb41dce095860456727748f63b478d625f119a70e0d764a" +dependencies = [ + "pathdiff", + "winapi", +] + [[package]] name = "openssl" version = "0.10.40" @@ -1755,6 +1765,12 @@ dependencies = [ "windows-sys 0.36.1", ] +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "percent-encoding" version = "2.1.0" @@ -2661,10 +2677,12 @@ dependencies = [ "minisign-verify", "objc", "once_cell", + "open", "os_info", "percent-encoding", "rand 0.8.5", "raw-window-handle", + "regex", "rfd", "semver 1.0.9", "serde", @@ -2690,7 +2708,7 @@ dependencies = [ [[package]] name = "tauri-appcenter-companion" -version = "0.3.6" +version = "0.4.0" dependencies = [ "cocoa", "serde", @@ -2728,6 +2746,7 @@ dependencies = [ "png 0.17.5", "proc-macro2", "quote", + "regex", "serde", "serde_json", "sha2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 20324cb..007ece7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-appcenter-companion" -version = "0.3.6" +version = "0.4.0" description = "Companion app for Appcenter" authors = ["Amaury CIVIER"] license = "GPL-3.0" @@ -18,7 +18,7 @@ tauri-build = { version = "1.0.0-rc.8", features = [] } serde_json = "1.0" window-vibrancy = "0.1.2" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "1.0.0-rc.10", features = ["http-all", "macos-private-api", "os-all", "updater", "window-start-dragging"] } +tauri = { version = "1.0.0-rc.10", features = ["http-all", "macos-private-api", "os-all", "shell-open", "updater", "window-start-dragging"] } [target."cfg(target_os = \"macos\")".dependencies] cocoa = "0.24" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b3b0142..6dbafbc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "package": { "productName": "AC Companion", - "version": "0.3.6" + "version": "0.4.0" }, "build": { "distDir": "../dist", @@ -56,6 +56,9 @@ "window": { "startDragging": true }, + "shell": { + "open": true + }, "http": { "all": true, "request": true, diff --git a/src/app/navigation/AppNavigator.tsx b/src/app/navigation/AppNavigator.tsx index b83efdb..82c0cd6 100644 --- a/src/app/navigation/AppNavigator.tsx +++ b/src/app/navigation/AppNavigator.tsx @@ -10,11 +10,12 @@ import { ThemeContext } from '@fluentui/react' import { os } from '@tauri-apps/api' -import { ApplicationList } from '../screens/application-list/ApplicationList' +import { ApplicationListScreen } from '../screens/application-list/ApplicationListScreen' import { Routes, Route, useNavigate, useLocation, Location } from 'react-router-dom' import './AppNavigator.css' import { APITokenListModal } from '../screens/api-token/APITokenListModal' import { AddBundledAppDialog } from '../screens/application-list/AddBundledAppDialog' +import { useConst } from '@fluentui/react-hooks' const navStyles = (theme: Theme): Partial => ({ root: { @@ -63,6 +64,7 @@ export const AppNavigator = () => { const navigate = useNavigate() const location = useLocation() const state = location.state as { backgroundLocation?: Location } + const titleBarHeight = useConst('35px') // set non transparent background for linux systems const [background, setBackground] = useState() @@ -76,7 +78,7 @@ export const AppNavigator = () => { return ( -
+
@@ -108,13 +110,15 @@ export const AppNavigator = () => { root: { backgroundColor: theme.semanticColors.bodyBackground, overflowX: 'hidden', + overflowY: 'auto', + height: `calc(100vh - ${titleBarHeight})`, borderRadius: '10px 0px 0px 0px' } }} tokens={{ padding: theme.spacing.m }} > - } /> + } /> {state?.backgroundLocation && ( diff --git a/src/app/screens/application-list/AddBundledAppDialog.tsx b/src/app/screens/application-list/AddBundledAppDialog.tsx index d59f095..315fdad 100644 --- a/src/app/screens/application-list/AddBundledAppDialog.tsx +++ b/src/app/screens/application-list/AddBundledAppDialog.tsx @@ -1,51 +1,23 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { DefaultButton, - DetailsHeader, Dialog, DialogFooter, DialogType, - IColumn, - IDetailsHeaderProps, - IGroup, PrimaryButton, Stack, TextField, - Selection, - DetailsList, - Spinner, - SpinnerSize + Selection } from '@fluentui/react' import { useNavigate } from 'react-router-dom' -import { observer } from 'mobx-react-lite' import { useForm } from 'react-hook-form' import { Branch, BundledApplicationSnapshotIn, useStores } from '../../../models' +import { SelectApplicationList } from './SelectApplicationList' import { useConst } from '@fluentui/react-hooks' -const columns: Array = [ - { - key: 'name', - name: 'Name', - fieldName: 'name', - minWidth: 100, - maxWidth: 200, - isResizable: true - }, - { - key: 'token', - name: 'token', - onRender: (item: Branch) => item.application?.token?.name, - minWidth: 100, - maxWidth: 200, - isResizable: true - } -] - -export const AddBundledAppDialog = observer(() => { +export const AddBundledAppDialog = () => { const { - applicationStore: { fetchApplications, applicationList }, - bundledApplicationStore: { addBundledApplication }, - tokenStore: { tokens } + bundledApplicationStore: { addBundledApplication } } = useStores() const navigate = useNavigate() @@ -58,8 +30,6 @@ export const AddBundledAppDialog = observer(() => { formState: { errors } } = useForm() - const [isLoading, setIsLoading] = useState(true) - // TODO: a rules to check that their is at least one branches React.useEffect(() => { register('branches') @@ -68,42 +38,16 @@ export const AddBundledAppDialog = observer(() => { const selection = useConst( () => - new Selection({ + new Selection({ onSelectionChanged: () => { setValue( 'branches', - (selection.getSelection() as Array).map((b) => b.id) + selection.getSelection().map((branch) => branch.id) ) } }) ) - useEffect(() => { - setIsLoading(true) - Promise.all( - tokens.map((token) => fetchApplications(token.token, { withBranches: true })) - ).finally(() => setIsLoading(false)) - }, []) - - const [items, groups] = (() => { - let items: Array = [] - const groups: Array = [] - for (const application of applicationList) { - const branches = application.configuredBranches - groups.push({ - key: application.id, - name: application.displayName, - startIndex: items.length, - count: branches.length, - level: 0, - isCollapsed: true - }) - items = items.concat(branches) - } - - return [items, groups] - })() - const onDismiss = () => { navigate(-1) } @@ -135,38 +79,7 @@ export const AddBundledAppDialog = observer(() => { {...register('name', { required: { value: true, message: 'Name is required' } })} errorMessage={errors.name?.message} /> - {isLoading ? ( - - ) : ( - ( - - )} - groupProps={{ - showEmptyGroups: true - }} - onRenderItemColumn={(item?: Branch, index?: number, column?: IColumn) => { - const value = - item && column && column.fieldName - ? item[column.fieldName as keyof Branch] || '' - : '' - - return
{value}
- }} - /> - )} +
@@ -175,4 +88,4 @@ export const AddBundledAppDialog = observer(() => { ) -}) +} diff --git a/src/app/screens/application-list/ApplicationList.tsx b/src/app/screens/application-list/ApplicationListScreen.tsx similarity index 90% rename from src/app/screens/application-list/ApplicationList.tsx rename to src/app/screens/application-list/ApplicationListScreen.tsx index 50a1f26..700ca54 100644 --- a/src/app/screens/application-list/ApplicationList.tsx +++ b/src/app/screens/application-list/ApplicationListScreen.tsx @@ -19,8 +19,10 @@ import { } from '@fluentui/react' import { useNavigate, useLocation } from 'react-router-dom' import { BuildButton, BuildStatusIndicator } from '../../components' +import { ApplicationMenuButton } from './ApplicationMenuButton' +import { BundledAppMenuButton } from './BundledAppMenuButton' -export const ApplicationList = observer(() => { +export const ApplicationListScreen = observer(() => { const { bundledApplicationStore: { bundledApplications, refresh, initialLoading }, branchStore: { branches } @@ -44,6 +46,27 @@ export const ApplicationList = observer(() => { setRenderTable(true) }, [initialLoading]) + let items: Array = [] + const groups: Array = [] + for (const application of bundledApplications) { + // force reload on application deps change + const branches = application.branches.map((b) => ({ + ...b, + application: b?.application, + lastBuild: b?.lastBuild + })) as Array + groups.push({ + key: application.id, + name: application.name, + data: application, + startIndex: items.length, + count: branches.length, + level: 0, + isCollapsed: false + } as IGroup) + items = items.concat(branches) + } + const columns: Array = [ { key: 'name', @@ -109,48 +132,28 @@ export const ApplicationList = observer(() => { { key: 'actions', name: 'Actions', - onRender: (item: Branch) => { + onRender: (item: Branch, index) => { const lastBuild = item.lastBuild - - if (lastBuild?.id) { - const branch = branches.get(item.id)! - return ( - + const group = groups.find((g) => g.startIndex <= index! && g.startIndex + g.count > index!)! + const branch = branches.get(item.id)! + return ( + + + {lastBuild && ( - - ) - } + )} + + ) }, - minWidth: 50 + minWidth: 70 } ] - let items: Array = [] - const groups: Array = [] - for (const application of bundledApplications) { - // force reload on application deps change - const branches = application.branches.map((b) => ({ - ...b, - application: b?.application, - lastBuild: b?.lastBuild - })) as Array - groups.push({ - key: application.id, - name: application.name, - data: application, - startIndex: items.length, - count: branches.length, - level: 0, - isCollapsed: false - } as IGroup) - items = items.concat(branches) - } - return ( <> { )}
+ { ) }} columns={columns} - ariaLabelForSelectAllCheckbox='Toggle selection for all items' - ariaLabelForSelectionColumn='Toggle selection' - checkButtonAriaLabel='select row' - checkButtonGroupAriaLabel='select section' /> )}
diff --git a/src/app/screens/application-list/ApplicationMenuButton.tsx b/src/app/screens/application-list/ApplicationMenuButton.tsx new file mode 100644 index 0000000..c6e27b5 --- /dev/null +++ b/src/app/screens/application-list/ApplicationMenuButton.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react' +import { + DefaultButton, + Dialog, + DialogFooter, + DialogType, + IconButton, + PrimaryButton +} from '@fluentui/react' +import { Branch, BundledApplication } from '../../../models' +import { open } from '@tauri-apps/api/shell' +import { useBoolean } from '@fluentui/react-hooks' + +export interface ApplicationMenuButtonProps { + branch: Branch + bundledApplication: BundledApplication +} + +export const ApplicationMenuButton = ({ + branch, + bundledApplication +}: ApplicationMenuButtonProps) => { + const [hideRemoveDialog, { toggle: toggleHideReemodeDialog }] = useBoolean(true) + const removeDialogContentProps = useMemo( + () => ({ + type: DialogType.normal, + title: `Remove ${branch.application.displayName} / ${branch.name}`, + closeButtonAriaLabel: 'Close', + subText: `Do you want to remove ${branch.application.displayName} / ${branch.name} from ${bundledApplication.name}` + }), + [branch, bundledApplication] + ) + + const onRemove = () => { + bundledApplication.removeBranch(branch) + toggleHideReemodeDialog() + } + + return ( + <> + null} + menuProps={{ + shouldFocusOnMount: true, + items: [ + { + key: 'open_appceenter', + text: 'Open in AppCenter', + iconProps: { iconName: 'Globe' }, + onClick: () => open(branch.url) + }, + { + key: 'remove', + text: 'Remove', + iconProps: { iconName: 'Delete' }, + onClick: toggleHideReemodeDialog + } + ] + }} + /> + + + ) +} diff --git a/src/app/screens/application-list/BundledAppMenuButton.tsx b/src/app/screens/application-list/BundledAppMenuButton.tsx new file mode 100644 index 0000000..d2e7362 --- /dev/null +++ b/src/app/screens/application-list/BundledAppMenuButton.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { + DefaultButton, + Dialog, + DialogFooter, + DialogType, + IconButton, + PrimaryButton, + Selection +} from '@fluentui/react' +import { Branch, BundledApplication, useStores } from '../../../models' +import { useBoolean, useConst } from '@fluentui/react-hooks' +import { SelectApplicationList } from './SelectApplicationList' + +export interface BundledAppMenuButtonProps { + bundledApplication: BundledApplication +} + +export const BundledAppMenuButton = ({ bundledApplication }: BundledAppMenuButtonProps) => { + const [hideRemoveDialog, { toggle: toggleHideRemoveDialog }] = useBoolean(true) + const [hideLinkAppDialog, { toggle: toggleHideLinkAppDialog }] = useBoolean(true) + const { bundledApplicationStore } = useStores() + + const onRemove = () => { + bundledApplicationStore.removeBundledApplication(bundledApplication) + toggleHideRemoveDialog() + } + + const selection = useConst(() => new Selection()) + + const onAddBranches = () => { + bundledApplication.addBranches(selection.getSelection()) + toggleHideLinkAppDialog() + } + + return ( + <> + null} + menuProps={{ + shouldFocusOnMount: true, + items: [ + { + key: 'link', + text: 'Add application', + iconProps: { iconName: 'AddLink' }, + onClick: toggleHideLinkAppDialog + }, + { + key: 'remove', + text: 'Remove', + iconProps: { iconName: 'Delete' }, + onClick: toggleHideRemoveDialog + } + ] + }} + /> + + + + ) +} diff --git a/src/app/screens/application-list/SelectApplicationList.tsx b/src/app/screens/application-list/SelectApplicationList.tsx new file mode 100644 index 0000000..352d5b5 --- /dev/null +++ b/src/app/screens/application-list/SelectApplicationList.tsx @@ -0,0 +1,120 @@ +import { + DetailsHeader, + DetailsList, + IColumn, + IDetailsHeaderProps, + IGroup, + Selection, + Spinner, + SpinnerSize +} from '@fluentui/react' +import { useConst } from '@fluentui/react-hooks' +import { Observer } from 'mobx-react-lite' +import React, { useEffect, useState } from 'react' +import { Branch, BundledApplication, useStores } from '../../../models' + +export interface SelectApplicationListProps { + currentBundledApplication?: BundledApplication + selection: Selection +} + +export const SelectApplicationList = ({ + currentBundledApplication, + selection +}: SelectApplicationListProps) => { + const { + applicationStore: { fetchApplications, applicationList }, + tokenStore: { tokens } + } = useStores() + + const [isLoading, setIsLoading] = useState(true) + + const columns = useConst>([ + { + key: 'name', + name: 'Name', + fieldName: 'name', + minWidth: 100, + maxWidth: 200, + isResizable: true + }, + { + key: 'token', + name: 'token', + onRender: (item: Branch) => item.application?.token?.name, + minWidth: 100, + maxWidth: 200, + isResizable: true + } + ]) + + useEffect(() => { + setIsLoading(true) + Promise.all( + tokens.map((token) => fetchApplications(token.token, { withBranches: true })) + ).finally(() => setIsLoading(false)) + }, []) + + return ( + + {() => { + const blackListBranches: Array = + (currentBundledApplication?.branches as Array) ?? [] + const [items, groups] = (() => { + let items: Array = [] + const groups: Array = [] + for (const application of applicationList) { + const branches = application.configuredBranches.filter( + (b) => !blackListBranches.includes(b) + ) + groups.push({ + key: application.id, + name: application.displayName, + startIndex: items.length, + count: branches.length, + level: 0, + isCollapsed: true + }) + items = items.concat(branches) + } + + return [items, groups] + })() + + return isLoading ? ( + // eslint-disable-next-line react/react-in-jsx-scope + + ) : ( + ( + + )} + groupProps={{ + showEmptyGroups: true + }} + onRenderItemColumn={(item?: Branch, index?: number, column?: IColumn) => { + const value = + item && column && column.fieldName + ? item[column.fieldName as keyof Branch] || '' + : '' + + return
{value}
+ }} + /> + ) + }} +
+ ) +} diff --git a/src/index.css b/src/index.css index d8c2c2c..9735c35 100644 --- a/src/index.css +++ b/src/index.css @@ -7,6 +7,10 @@ body { background-color: rgba(255, 255, 255, 0.4) !important; } +html { + overflow: hidden; +} + @media (prefers-color-scheme: dark) { body { background-color: rgba(0, 0, 0, 0.6) !important; diff --git a/src/models/branch-store/branch/branch.ts b/src/models/branch-store/branch/branch.ts index 53ad65e..23d0143 100644 --- a/src/models/branch-store/branch/branch.ts +++ b/src/models/branch-store/branch/branch.ts @@ -29,6 +29,13 @@ export const BranchModel = types .views((self) => ({ get isBuildable() { return self.application.token.access === 'fullAccess' + }, + get url() { + const baseUrl = self.environment.appcenterUrl + const ownner = self.application.owner.displayName + const application = self.application.name + const branch = self.name + return `${baseUrl}orgs/${ownner}/apps/${application}/build/branches/${branch}` } })) .actions((self) => ({ diff --git a/src/models/bundled-application-store/bundled-application-store.ts b/src/models/bundled-application-store/bundled-application-store.ts index 085e4fa..5c25270 100644 --- a/src/models/bundled-application-store/bundled-application-store.ts +++ b/src/models/bundled-application-store/bundled-application-store.ts @@ -1,4 +1,5 @@ import { flow, Instance, SnapshotOut, types } from 'mobx-state-tree' +import { BundledApplication } from '..' import { Application } from '../application-store' import { withEnvironment } from '../extensions/extensions' import { BundledApplicationModel, BundledApplicationSnapshotIn } from './bundled-application' @@ -19,6 +20,9 @@ export const BundledApplicationStoreModel = types addBundledApplication(bundledApplication: BundledApplicationSnapshotIn) { self.bundledApplications.push(bundledApplication) }, + removeBundledApplication(bundledApplication: BundledApplication) { + self.bundledApplications.remove(bundledApplication) + }, refresh: flow(function* () { const appsToFetch: Record = {} self.bundledApplications.forEach((bundledApplication) => { diff --git a/src/models/bundled-application-store/bundled-application/bundled-application.ts b/src/models/bundled-application-store/bundled-application/bundled-application.ts index 621810a..bf54e0b 100644 --- a/src/models/bundled-application-store/bundled-application/bundled-application.ts +++ b/src/models/bundled-application-store/bundled-application/bundled-application.ts @@ -1,7 +1,7 @@ import { Instance, SnapshotIn, SnapshotOut, types } from 'mobx-state-tree' import { v4 as uuid } from 'uuid' import { BuildResult, BuildStatus } from '../../../services' -import { BranchModel } from '../../branch-store' +import { Branch, BranchModel } from '../../branch-store' /** * Model description here for TypeScript hints. @@ -42,11 +42,20 @@ export const BundledApplicationModel = types } })) .actions((self) => ({ + addBranch(branch: Branch) { + self.branches.push(branch) + }, + addBranches(branches: Branch[]) { + self.branches.push(...branches) + }, startBuild() { self.branches.forEach((b) => b?.startBuild()) }, cancelBuild() { self.branches.forEach((b) => b?.cancelBuild()) + }, + removeBranch(branch: Branch) { + self.branches.remove(branch) } })) diff --git a/src/models/bundled-application-store/set-up-bundled-application-ws.ts b/src/models/bundled-application-store/set-up-bundled-application-ws.ts index 5930e10..ed9baa8 100644 --- a/src/models/bundled-application-store/set-up-bundled-application-ws.ts +++ b/src/models/bundled-application-store/set-up-bundled-application-ws.ts @@ -1,5 +1,5 @@ import { onSnapshot } from 'mobx-state-tree' -import { Subscription, Subject, concatMap, distinct } from 'rxjs' +import { Subscription, Subject, concatMap, distinct, debounceTime } from 'rxjs' import { BundledApplicationStoreSnapshot } from '..' import { AppcenterApi } from '../../services' import { AppWebSocketChannel } from '../../websockets/app-websocket-channel' @@ -28,6 +28,7 @@ export const setUpBundledApplicationWs = (rootStore: RootStore, appcenterApi: Ap storeSnapshot .pipe( distinct(), + debounceTime(100), concatMap((snapshot) => { return disconectAllWebSockets().then(() => snapshot) }), diff --git a/src/models/environment.ts b/src/models/environment.ts index 3fd015e..fee79e8 100644 --- a/src/models/environment.ts +++ b/src/models/environment.ts @@ -2,7 +2,9 @@ import { AppcenterApi } from '../services' export class Environment { readonly appcenterApi: AppcenterApi - private constructor({ appcenterApi }: Environment) { + readonly appcenterUrl: string = 'https://appcenter.ms/' + + private constructor({ appcenterApi }: Omit) { this.appcenterApi = appcenterApi } diff --git a/src/websockets/app-websocket-channel.ts b/src/websockets/app-websocket-channel.ts index bb9386f..a437262 100644 --- a/src/websockets/app-websocket-channel.ts +++ b/src/websockets/app-websocket-channel.ts @@ -112,8 +112,11 @@ export class AppWebSocketChannel { private _onMessage(message: Message) { if (message.type === 'Close') { - console.warn(message) - this._reconnect() + // If not disconnected by client + console.debug(message) + if (message.data?.code !== 1000) { + this._reconnect() + } return } if (message.type !== 'Text') { @@ -178,6 +181,7 @@ export class AppWebSocketChannel { async close() { // public close differ from private close because it will also terminate the event subject for the consummeer + delete AppWebSocketChannel._openedWebSockets[this._branch.application.id] await this._close() this._eventSubject.complete() }