Line data Source code
1 : /*
2 : * Copyright (C) 2017 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 346 : import cockpit from "cockpit";
7 346 : import React from "react";
8 : import { debounce } from 'throttle-debounce';
9 : import { TypeaheadSelect } from "cockpit-components-typeahead-select";
10 :
11 346 : const _ = cockpit.gettext;
12 :
13 : interface FileEntry {
14 : type: "file" | "directory" | "link" | "special";
15 : path: string;
16 : }
17 :
18 : interface FileAutoCompleteProps {
19 : id?: string;
20 : placeholder?: string;
21 : superuser?: cockpit.SuperuserMode;
22 : isOptionCreatable: boolean;
23 : onlyDirectories: boolean;
24 : onChange: (value: string, error?: string | null) => void;
25 : value?: string;
26 : }
27 :
28 : interface FileAutoCompleteState {
29 : directory: string;
30 : displayFiles: FileEntry[];
31 : value: string | null;
32 : error?: string | null;
33 : }
34 :
35 346 : export class FileAutoComplete extends React.Component<FileAutoCompleteProps, FileAutoCompleteState> {
36 346 : static defaultProps: Partial<FileAutoCompleteProps> = {
37 346 : isOptionCreatable: false,
38 346 : onlyDirectories: false,
39 5 : onChange: () => '',
40 346 : };
41 :
42 : allowFilesUpdate: boolean;
43 : debouncedChange: (value: string) => void;
44 :
45 6 : constructor(props: FileAutoCompleteProps) {
46 6 : super(props);
47 6 : this.state = {
48 6 : directory: '', // The current directory we list files/dirs from
49 6 : displayFiles: [],
50 6 : value: this.props.value || null,
51 6 : };
52 :
53 6 : this.allowFilesUpdate = true;
54 6 : this.clearSelection = this.clearSelection.bind(this);
55 :
56 6 : this.debouncedChange = debounce(300, this.onPathChange);
57 6 : }
58 :
59 6 : onPathChange = (value: string) => {
60 6 : if (!value) {
61 6 : this.clearSelection();
62 6 : return;
63 6 : }
64 :
65 0 : const cb = (dirPath: string) => this.updateFiles(dirPath == '' ? '/' : dirPath);
66 :
67 6 : let path = value;
68 6 : if (value.lastIndexOf('/') == value.length - 1)
69 6 : path = value.slice(0, value.length - 1);
70 :
71 6 : const match = this.state.displayFiles
72 4 : .find(entry => (entry.type == 'directory' && entry.path == path + '/') || (entry.type == 'file' && entry.path == path));
73 :
74 4 : if (match) {
75 : // If match file path is a prefix of another file, do not update current directory,
76 : // since we cannot tell file/directory user wants to select
77 : // https://bugzilla.redhat.com/show_bug.cgi?id=2097662
78 4 : const isPrefix = this.state.displayFiles.filter(entry => entry.path.startsWith(value)).length > 1;
79 : // If the inserted string corresponds to a directory listed in the results
80 : // update the current directory and refetch results
81 4 : if (match.type == 'directory' && !isPrefix)
82 4 : cb(match.path);
83 : else
84 4 : this.setState({ value: match.path });
85 4 : } else {
86 : // If the inserted string's parent directory is not matching the `directory`
87 : // in the state object we need to update the parent directory and recreate the displayFiles
88 6 : const parentDir = value.slice(0, value.lastIndexOf('/'));
89 :
90 6 : if (parentDir + '/' != this.state.directory) {
91 6 : return this.updateFiles(parentDir + '/');
92 6 : }
93 6 : }
94 6 : };
95 :
96 6 : componentDidMount() {
97 6 : this.onPathChange(this.state.value || '');
98 6 : }
99 :
100 1 : componentWillUnmount() {
101 1 : this.allowFilesUpdate = false;
102 1 : }
103 :
104 6 : updateFiles(path: string) {
105 6 : if (this.state.directory == path)
106 6 : return;
107 :
108 6 : const channel = cockpit.channel({
109 6 : payload: "fslist1",
110 6 : path,
111 6 : superuser: this.props.superuser,
112 6 : watch: false,
113 6 : });
114 6 : const results: FileEntry[] = [];
115 :
116 6 : channel.addEventListener("ready", () => {
117 6 : this.finishUpdate(results, null, path);
118 6 : });
119 :
120 6 : channel.addEventListener("close", (_ev, data) => {
121 6 : this.finishUpdate(results, data.message as string | null, path);
122 6 : });
123 :
124 6 : channel.addEventListener("message", (_ev, data) => {
125 6 : const item = JSON.parse(data);
126 6 : if (item && item.path && item.event == 'present' &&
127 0 : (!this.props.onlyDirectories || item.type == 'directory')) {
128 6 : item.path = item.path + (item.type == 'directory' ? '/' : '');
129 6 : results.push(item);
130 6 : }
131 6 : });
132 6 : }
133 :
134 6 : finishUpdate(results: FileEntry[], error: string | null, directory: string) {
135 6 : if (!this.allowFilesUpdate)
136 6 : return;
137 6 : results = results.sort((a, b) => a.path.localeCompare(b.path));
138 :
139 6 : const listItems: FileEntry[] = results.map(file => ({
140 6 : type: file.type,
141 0 : path: (directory == '' ? '/' : directory) + file.path
142 6 : }));
143 :
144 6 : if (directory) {
145 6 : listItems.unshift({
146 6 : type: "directory",
147 6 : path: directory
148 6 : });
149 6 : }
150 :
151 6 : if (error || !this.state.value)
152 6 : this.props.onChange('', error);
153 :
154 6 : if (!error)
155 6 : this.setState({ displayFiles: listItems, directory });
156 6 : this.setState({
157 6 : error,
158 6 : });
159 6 : }
160 :
161 6 : clearSelection() {
162 6 : this.updateFiles("/");
163 6 : this.setState({ value: null });
164 6 : this.props.onChange('', null);
165 6 : }
166 :
167 6 : render() {
168 5 : const placeholder = this.props.placeholder || _("Path to file");
169 :
170 6 : const selectOptions = this.state.displayFiles
171 6 : .map(option => ({ value: option.path, content: option.path, className: option.type }));
172 :
173 6 : return (
174 6 : <TypeaheadSelect toggleProps={{ id: this.props.id }}
175 6 : isScrollable
176 6 : onInputChange={this.debouncedChange}
177 6 : placeholder={placeholder}
178 6 : noOptionsAvailableMessage={this.state.error || _("No such file or directory")}
179 6 : noOptionsFoundMessage={this.state.error || _("No such file or directory")}
180 4 : onToggle={isOpen => {
181 : // Try to list again when
182 : // opening. Calling onPathChange here
183 : // usually does nothing, except when
184 : // there was an error earlier.
185 4 : if (isOpen)
186 2 : this.onPathChange(this.state.value || '');
187 4 : }}
188 6 : selected={this.state.value}
189 6 : selectedIsTrusted
190 4 : onSelect={(_, value) => {
191 4 : const path = String(value);
192 4 : this.setState({ value: path });
193 4 : this.onPathChange(path);
194 4 : this.props.onChange(path, null);
195 4 : }}
196 6 : onClearSelection={this.clearSelection}
197 6 : isCreatable={this.props.isOptionCreatable}
198 0 : createOptionMessage={val => cockpit.format(_("Create $0"), val)}
199 6 : selectOptions={selectOptions} />
200 : );
201 6 : }
202 346 : }
|