Skip to content

Commit dd6fee8

Browse files
authored
905 add ability to list files and directories (#909)
* Start adding MAVFtp integration * Fix bugs with ftp controller, add socket endpoint for listing files * Add reset sessions and basic ftp controller tests * Add config page integration * Update ftp_controller tests * Address copilot review comments * Add files refresh * Add loading overlay * Address copilot review comments * Add tests for ftp endpoint * Fix ftp test
1 parent 1990c4c commit dd6fee8

13 files changed

Lines changed: 916 additions & 8 deletions

File tree

gcs/src/components/config/ftp.jsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
This is the FTP component for the config page.
3+
4+
It handles all FTP related operations.
5+
*/
6+
// Base imports
7+
8+
// 3rd party imports
9+
10+
// Redux
11+
import { Button, Group, LoadingOverlay, Tree } from "@mantine/core"
12+
import { IconFile, IconFolder, IconFolderOpen } from "@tabler/icons-react"
13+
import { useEffect, useMemo } from "react"
14+
import { useDispatch, useSelector } from "react-redux"
15+
import {
16+
emitListFiles,
17+
resetFiles,
18+
selectFiles,
19+
selectLoadingListFiles,
20+
} from "../../redux/slices/ftpSlice"
21+
22+
export default function Ftp() {
23+
const dispatch = useDispatch()
24+
const files = useSelector(selectFiles)
25+
const loadingListFiles = useSelector(selectLoadingListFiles)
26+
27+
const convertedFiles = useMemo(() => {
28+
if (!files || files.length === 0) return []
29+
return files.map((file) => {
30+
// Need to convert all children recursively
31+
32+
const convertChildren = (children) => {
33+
if (!children) return undefined
34+
return children.map((child) => {
35+
return {
36+
value: child.path,
37+
label: child.name,
38+
children: convertChildren(child.children),
39+
path: child.path,
40+
is_dir: child.is_dir,
41+
}
42+
})
43+
}
44+
45+
return {
46+
value: file.path,
47+
label: file.name,
48+
children: convertChildren(file.children),
49+
path: file.path,
50+
is_dir: file.is_dir,
51+
}
52+
})
53+
}, [files])
54+
55+
useEffect(() => {
56+
if (files.length === 0) {
57+
dispatch(emitListFiles({ path: "/" }))
58+
}
59+
}, [files.length, dispatch])
60+
61+
function handleFileClick(node) {
62+
if (node.is_dir) {
63+
if (node.children === undefined) {
64+
dispatch(emitListFiles({ path: node.path }))
65+
}
66+
}
67+
}
68+
69+
return (
70+
<div className="flex flex-col gap-4 mx-4 relative w-fit">
71+
<LoadingOverlay
72+
visible={loadingListFiles}
73+
zIndex={1000}
74+
overlayProps={{ blur: 2 }}
75+
/>
76+
77+
{loadingListFiles && <p>Loading files...</p>}
78+
79+
<Button
80+
onClick={() => {
81+
dispatch(resetFiles())
82+
}}
83+
w={"fit-content"}
84+
>
85+
Refresh files
86+
</Button>
87+
<Tree
88+
data={convertedFiles}
89+
renderNode={({ node, expanded, elementProps }) => (
90+
<Group gap={5} {...elementProps} key={node.path}>
91+
{node.is_dir ? (
92+
<>
93+
{expanded ? (
94+
<IconFolderOpen size={20} />
95+
) : (
96+
<IconFolder size={20} />
97+
)}
98+
</>
99+
) : (
100+
<IconFile size={20} />
101+
)}
102+
103+
<span
104+
onClick={() => {
105+
handleFileClick(node)
106+
}}
107+
>
108+
{node.label}
109+
</span>
110+
</Group>
111+
)}
112+
/>
113+
</div>
114+
)
115+
}

gcs/src/config.jsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import NoDroneConnected from "./components/noDroneConnected"
2020

2121
// Redux
2222
import { useDispatch, useSelector } from "react-redux"
23-
import { selectConnectedToDrone } from "./redux/slices/droneConnectionSlice"
23+
import Ftp from "./components/config/ftp"
2424
import {
2525
emitGetGripperEnabled,
2626
selectGetGripperEnabled,
2727
} from "./redux/slices/configSlice"
28+
import { selectConnectedToDrone } from "./redux/slices/droneConnectionSlice"
2829

2930
export default function Config() {
3031
const dispatch = useDispatch()
@@ -63,6 +64,7 @@ export default function Config() {
6364
<Tabs.Tab value="motor_test">Motor Test</Tabs.Tab>
6465
<Tabs.Tab value="rc_calibration">RC Calibration</Tabs.Tab>
6566
<Tabs.Tab value="flightmodes">Flight modes</Tabs.Tab>
67+
<Tabs.Tab value="ftp">FTP</Tabs.Tab>
6668
</Tabs.List>
6769
<Tabs.Panel value="gripper">
6870
<div className={paddingTop}>
@@ -84,6 +86,11 @@ export default function Config() {
8486
<FlightModes />
8587
</div>
8688
</Tabs.Panel>
89+
<Tabs.Panel value="ftp">
90+
<div className={paddingTop}>
91+
<Ftp />
92+
</div>
93+
</Tabs.Panel>
8794
</Tabs>
8895
</div>
8996
) : (

gcs/src/redux/middleware/emitters.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
setCurrentPage,
3636
setIsForwarding,
3737
} from "../slices/droneConnectionSlice"
38+
import { emitListFiles, setLoadingListFiles } from "../slices/ftpSlice"
3839
import {
3940
emitControlMission,
4041
emitExportMissionToFile,
@@ -373,6 +374,15 @@ export function handleEmitters(socket, store, action) {
373374
})
374375
},
375376
},
377+
{
378+
emitter: emitListFiles,
379+
callback: () => {
380+
socket.socket.emit("list_files", {
381+
path: action.payload.path,
382+
})
383+
store.dispatch(setLoadingListFiles(true))
384+
},
385+
},
376386
]
377387

378388
for (const { emitter, callback } of emitHandlers) {

gcs/src/redux/middleware/socketMiddleware.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ import {
6060
setEkfStatusReportData,
6161
setExtraData,
6262
setFlightSwVersion,
63+
setGps2RawIntData,
6364
setGpsData,
6465
setGpsRawIntData,
65-
setGps2RawIntData,
6666
setGuidedModePinData,
6767
setHeartbeatData,
6868
setHomePosition,
@@ -74,6 +74,11 @@ import {
7474
setTelemetryData,
7575
setVibrationData,
7676
} from "../slices/droneInfoSlice"
77+
import {
78+
addFiles,
79+
resetFiles,
80+
setLoadingListFiles,
81+
} from "../slices/ftpSlice.js"
7782
import {
7883
addIdToItem,
7984
closeDashboardMissionFetchingNotificationNoSuccessThunk,
@@ -170,6 +175,10 @@ const ConfigSpecificSocketEvents = Object.freeze({
170175
onSetRcConfigResult: "set_rc_config_result",
171176
})
172177

178+
const FtpSpecificSocketEvents = Object.freeze({
179+
onListFilesResult: "list_files_result",
180+
})
181+
173182
const socketMiddleware = (store) => {
174183
let socket
175184

@@ -398,6 +407,7 @@ const socketMiddleware = (store) => {
398407
store.dispatch(setShowMotorTestWarningModal(true))
399408
store.dispatch(resetMessages())
400409
store.dispatch(resetGpsTrack())
410+
store.dispatch(resetFiles())
401411
})
402412

403413
// Link stats
@@ -1064,6 +1074,15 @@ const socketMiddleware = (store) => {
10641074
}
10651075
},
10661076
)
1077+
1078+
socket.socket.on(FtpSpecificSocketEvents.onListFilesResult, (msg) => {
1079+
store.dispatch(setLoadingListFiles(false))
1080+
if (msg.success) {
1081+
store.dispatch(addFiles(msg.data))
1082+
} else {
1083+
showErrorNotification(msg.message)
1084+
}
1085+
})
10671086
} else {
10681087
// Turn off socket events
10691088
Object.values(DroneSpecificSocketEvents).map((event) =>

gcs/src/redux/slices/ftpSlice.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { createSlice } from "@reduxjs/toolkit"
2+
3+
const ftpSlice = createSlice({
4+
name: "ftp",
5+
initialState: {
6+
files: [],
7+
loadingListFiles: false,
8+
},
9+
reducers: {
10+
resetFiles: (state) => {
11+
state.files = []
12+
},
13+
addFiles: (state, action) => {
14+
// Filter out any files that are "." or ".." or already exist in top level files
15+
const filteredNewFiles = action.payload.filter((newFile) => {
16+
return (
17+
newFile.name !== "." &&
18+
newFile.name !== ".." &&
19+
!state.files.some(
20+
(existingFile) => existingFile.path === newFile.path,
21+
)
22+
)
23+
})
24+
25+
for (const file of filteredNewFiles) {
26+
// Find parent directory
27+
let parentPath = file.path.split("/").slice(0, -1).join("/")
28+
if (parentPath === "") {
29+
parentPath = "/"
30+
}
31+
32+
if (parentPath === "/") {
33+
// Add top level files since they don't exist already
34+
state.files.push(file)
35+
continue
36+
}
37+
// Try find parentDir recursively
38+
const parentDir = (function findParentDir(directories, targetPath) {
39+
for (const dir of directories) {
40+
if (dir.path === targetPath) {
41+
return dir
42+
}
43+
if (dir.children) {
44+
const found = findParentDir(dir.children, targetPath)
45+
if (found) {
46+
return found
47+
}
48+
}
49+
}
50+
return null
51+
})(state.files, parentPath)
52+
53+
if (parentDir) {
54+
if (!parentDir.children) {
55+
parentDir.children = []
56+
}
57+
parentDir.children.push(file)
58+
} else {
59+
console.warn(
60+
`File "${file.name}" with path "${file.path}" could not be added because its parent directory "${parentPath}" is missing in state`,
61+
)
62+
}
63+
}
64+
},
65+
setLoadingListFiles: (state, action) => {
66+
state.loadingListFiles = action.payload
67+
},
68+
69+
emitListFiles: () => {},
70+
},
71+
selectors: {
72+
selectFiles: (state) => state.files,
73+
selectLoadingListFiles: (state) => state.loadingListFiles,
74+
},
75+
})
76+
77+
export const { resetFiles, addFiles, setLoadingListFiles, emitListFiles } =
78+
ftpSlice.actions
79+
80+
export const { selectFiles, selectLoadingListFiles } = ftpSlice.selectors
81+
82+
export default ftpSlice

gcs/src/redux/store.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { combineSlices, configureStore } from "@reduxjs/toolkit"
2-
import droneInfoSlice, { setGraphValues } from "./slices/droneInfoSlice"
3-
import logAnalyserSlice from "./slices/logAnalyserSlice"
4-
import socketSlice from "./slices/socketSlice"
5-
import applicationSlice from "./slices/applicationSlice"
62
import socketMiddleware from "./middleware/socketMiddleware"
3+
import applicationSlice from "./slices/applicationSlice"
74
import configSlice from "./slices/configSlice"
85
import droneConnectionSlice, {
96
setBaudrate,
@@ -17,8 +14,12 @@ import droneConnectionSlice, {
1714
setSelectedComPorts,
1815
setWireless,
1916
} from "./slices/droneConnectionSlice"
17+
import droneInfoSlice, { setGraphValues } from "./slices/droneInfoSlice"
18+
import ftpSlice from "./slices/ftpSlice"
19+
import logAnalyserSlice from "./slices/logAnalyserSlice"
2020
import missionInfoSlice, { setPlannedHomePosition } from "./slices/missionSlice"
2121
import paramsSlice from "./slices/paramsSlice"
22+
import socketSlice from "./slices/socketSlice"
2223
import statusTextSlice from "./slices/statusTextSlice"
2324

2425
const rootReducer = combineSlices(
@@ -31,6 +32,7 @@ const rootReducer = combineSlices(
3132
paramsSlice,
3233
configSlice,
3334
applicationSlice,
35+
ftpSlice,
3436
)
3537

3638
export const store = configureStore({

0 commit comments

Comments
 (0)