Skip to content

Commit 9cbfd41

Browse files
committed
feat: improved support for selecting profiles from the profile explorer
Including having the `codeflare` terminal pay attention to the selected profile
1 parent a259e97 commit 9cbfd41

File tree

9 files changed

+245
-109
lines changed

9 files changed

+245
-109
lines changed

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/plugin-client-default/notebooks/hello.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ and machine learning pipelines on the cloud.
4444
outputOnly: true
4545
maximize: true
4646
---
47-
codeflare terminal codeflare
47+
codeflare terminal codeflare -p ${SELECTED_PROFILE}
4848
```
4949

5050
=== "Gallery"
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright 2022 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React from "react"
18+
import prettyMillis from "pretty-ms"
19+
import { EventEmitter } from "events"
20+
import { Profiles } from "madwizard"
21+
import { Loading } from "@kui-shell/plugin-client-common"
22+
import { Grid, GridItem, Tile } from "@patternfly/react-core"
23+
24+
import ProfileWatcher from "../tray/watchers/profile/list"
25+
26+
import PlusIcon from "@patternfly/react-icons/dist/esm/icons/user-plus-icon"
27+
// import ProfileIcon from "@patternfly/react-icons/dist/esm/icons/user-icon"
28+
29+
const events = new EventEmitter()
30+
31+
function emitSelectProfile(profile: string) {
32+
events.emit("/profile/select", profile)
33+
}
34+
35+
export function onSelectProfile(cb: (profile: string) => void) {
36+
events.on("/profile/select", cb)
37+
}
38+
39+
export function offSelectProfile(cb: (profile: string) => void) {
40+
events.off("/profile/select", cb)
41+
}
42+
43+
type Props = Record<string, never>
44+
45+
type State = {
46+
watcher: ProfileWatcher
47+
selectedProfile?: string
48+
profiles?: Profiles.Profile[]
49+
catastrophicError?: unknown
50+
}
51+
52+
export default class ProfileExplorer extends React.PureComponent<Props, State> {
53+
public constructor(props: Props) {
54+
super(props)
55+
this.init()
56+
}
57+
58+
private readonly updateFn = () => {
59+
// slice to force a render; TODO we could do a comparison to avoid
60+
// false re-renders if we want to get fancy
61+
this.setState((curState) => {
62+
const profiles = curState.watcher.profiles.slice()
63+
if (!curState || !curState.profiles || curState.profiles.length === 0) {
64+
// sort the first time we get a list of profiles; TODO should
65+
// we re-sort if the list changes? what we want to avoid is
66+
// resorting simply because the selection changed
67+
profiles.sort((a, b) => b.lastUsedTime - a.lastUsedTime)
68+
}
69+
return {
70+
profiles,
71+
}
72+
})
73+
}
74+
75+
private async init() {
76+
try {
77+
const watcher = await new ProfileWatcher(this.updateFn, await Profiles.profilesPath({}, true)).init()
78+
this.setState({
79+
watcher,
80+
profiles: [],
81+
})
82+
} catch (err) {
83+
console.error(err)
84+
this.setState({ catastrophicError: err })
85+
}
86+
}
87+
88+
public componentWillUnmount() {
89+
if (this.state && this.state.watcher) {
90+
this.state.watcher.close()
91+
}
92+
}
93+
94+
/** User has clicked to select a profile */
95+
private readonly onSelect = async (evt: React.MouseEvent<HTMLElement>) => {
96+
const selectedProfile = evt.currentTarget.getAttribute("data-profile")
97+
evt.currentTarget.scrollIntoView(true)
98+
if (selectedProfile) {
99+
if (await Profiles.bumpLastUsedTime(selectedProfile)) {
100+
emitSelectProfile(selectedProfile)
101+
this.setState({ selectedProfile })
102+
}
103+
}
104+
}
105+
106+
public render() {
107+
if (this.state && this.state.catastrophicError) {
108+
return "Internal Error"
109+
} else if (!this.state || !this.state.profiles) {
110+
return <Loading />
111+
} else {
112+
return (
113+
<Grid className="codeflare--gallery-grid flex-fill sans-serif top-pad left-pad right-pad bottom-pad" hasGutter>
114+
{this.state.profiles.map((_, idx) => (
115+
<GridItem key={_.name}>
116+
<Tile
117+
className="codeflare--tile"
118+
data-profile={_.name}
119+
title={_.name}
120+
isSelected={!this.state.selectedProfile ? idx === 0 : this.state.selectedProfile === _.name}
121+
onClick={this.onSelect}
122+
>
123+
{`Last used ${prettyMillis(Date.now() - _.lastUsedTime, { compact: true })} ago`}
124+
</Tile>
125+
</GridItem>
126+
))}
127+
128+
{
129+
<GridItem>
130+
<Tile className="codeflare--tile codeflare--tile-new" title="New Profile" icon={<PlusIcon />} isStacked>
131+
Customize a profile
132+
</Tile>
133+
</GridItem>
134+
}
135+
</Grid>
136+
)
137+
}
138+
}
139+
}

plugins/plugin-codeflare/src/components/RestartableTerminal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function watch(stream: PassThrough, job: Job) {
3434
}
3535
}
3636

37-
type Props = {
37+
export type Props = {
3838
cmdline: string
3939
env: Record<string, string>
4040
tab: Arguments["tab"]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2022 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React from "react"
18+
import { Profiles } from "madwizard"
19+
import { Loading } from "@kui-shell/plugin-client-common"
20+
21+
import Terminal, { Props } from "./RestartableTerminal"
22+
import { onSelectProfile, offSelectProfile } from "./ProfileExplorer"
23+
24+
type State = {
25+
cmdline?: string
26+
}
27+
28+
export default class SelectedProfileTerminal extends React.PureComponent<Props, State> {
29+
public static readonly selectedProfilePattern = /\$\{SELECTED_PROFILE\}/g
30+
31+
public constructor(props: Props) {
32+
super(props)
33+
onSelectProfile(this.onSelect)
34+
this.init()
35+
}
36+
37+
public componentWillUnmount() {
38+
offSelectProfile(this.onSelect)
39+
}
40+
41+
private readonly onSelect = async (selectedProfile: string) => {
42+
const cmdline = await this.cmdline(selectedProfile)
43+
this.setState({ cmdline })
44+
}
45+
46+
private async init() {
47+
const cmdline = await this.cmdline()
48+
this.setState({ cmdline })
49+
}
50+
51+
private async cmdline(selectedProfile?: string) {
52+
return this.props.cmdline.replace(
53+
SelectedProfileTerminal.selectedProfilePattern,
54+
selectedProfile || (await Profiles.lastUsed())
55+
)
56+
}
57+
58+
public render() {
59+
if (!this.state || !this.state.cmdline) {
60+
return <Loading />
61+
} else {
62+
return <Terminal key={this.state.cmdline} {...this.props} cmdline={this.state.cmdline} />
63+
}
64+
}
65+
}

plugins/plugin-codeflare/src/controller/profile/get.tsx

Lines changed: 1 addition & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -15,87 +15,8 @@
1515
*/
1616

1717
import React from "react"
18-
import prettyMillis from "pretty-ms"
19-
import { Profiles } from "madwizard"
20-
import { Loading } from "@kui-shell/plugin-client-common"
21-
import { Grid, GridItem, Tile } from "@patternfly/react-core"
18+
import ProfileExplorer from "../../components/ProfileExplorer"
2219

23-
import ProfileWatcher from "../../tray/watchers/profile/list"
24-
25-
import PlusIcon from "@patternfly/react-icons/dist/esm/icons/user-plus-icon"
26-
import ProfileIcon from "@patternfly/react-icons/dist/esm/icons/user-icon"
27-
28-
type Props = Record<string, never>
29-
30-
type State = {
31-
watcher: ProfileWatcher
32-
profiles: Profiles.Profile[]
33-
catastrophicError?: unknown
34-
}
35-
36-
class ProfileExplorer extends React.PureComponent<Props, State> {
37-
public constructor(props: Props) {
38-
super(props)
39-
this.init()
40-
}
41-
42-
private readonly updateFn = () => {
43-
// slice to force a render; TODO we could do a comparison to avoid
44-
// false re-renders if we want to get fancy
45-
this.setState((curState) => ({
46-
profiles: curState.watcher.profiles.slice(),
47-
}))
48-
}
49-
50-
private async init() {
51-
try {
52-
const watcher = await new ProfileWatcher(this.updateFn, await Profiles.profilesPath({}, true)).init()
53-
this.setState({
54-
watcher,
55-
profiles: [],
56-
})
57-
} catch (err) {
58-
console.error(err)
59-
this.setState({ catastrophicError: err })
60-
}
61-
}
62-
63-
public componentWillUnmount() {
64-
if (this.state && this.state.watcher) {
65-
this.state.watcher.close()
66-
}
67-
}
68-
69-
public render() {
70-
if (this.state && this.state.catastrophicError) {
71-
return "Internal Error"
72-
} else if (!this.state || !this.state.profiles) {
73-
return <Loading />
74-
} else {
75-
return (
76-
<Grid className="codeflare--gallery-grid flex-fill sans-serif top-pad left-pad right-pad bottom-pad" hasGutter>
77-
{this.state.profiles.map((_) => (
78-
<GridItem key={_.name}>
79-
<Tile className="codeflare--tile" title={_.name} icon={<ProfileIcon />} isStacked>
80-
{`Last used ${prettyMillis(Date.now() - _.lastUsedTime, { compact: true })} ago`}
81-
</Tile>
82-
</GridItem>
83-
))}
84-
85-
{
86-
<GridItem>
87-
<Tile className="codeflare--tile codeflare--tile-new" title="New Profile" icon={<PlusIcon />} isStacked>
88-
Customize a profile
89-
</Tile>
90-
</GridItem>
91-
}
92-
</Grid>
93-
)
94-
}
95-
}
96-
}
97-
98-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
9920
export default async function getProfiles() {
10021
return {
10122
react: <ProfileExplorer />,

plugins/plugin-codeflare/src/controller/terminal.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import React from "react"
1818
import { Arguments, encodeComponent } from "@kui-shell/core"
1919

2020
import respawn from "./respawn"
21+
2122
import Terminal from "../components/RestartableTerminal"
23+
import SelectedProfileTerminal from "../components/SelectedProfileTerminal"
2224

2325
/**
2426
* This is a command handler that opens up a terminal. The expectation
@@ -29,7 +31,11 @@ export default function openTerminal(args: Arguments) {
2931
// respawn, meaning launch it with codeflare
3032
const { argv, env } = respawn(args.argv.slice(2))
3133
const cmdline = argv.map((_) => encodeComponent(_)).join(" ")
34+
3235
return {
33-
react: <Terminal cmdline={cmdline} env={env} repl={args.REPL} tab={args.tab} />,
36+
react: React.createElement(
37+
SelectedProfileTerminal.selectedProfilePattern.test(args.command) ? SelectedProfileTerminal : Terminal,
38+
{ cmdline, env, repl: args.REPL, tab: args.tab }
39+
),
3440
}
3541
}

0 commit comments

Comments
 (0)