Line data Source code
1 : /*
2 : * Copyright (C) 2017 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 125 : import cockpit from "cockpit";
7 125 : import React from "react";
8 : import { debounce } from 'throttle-debounce';
9 : import { TypeaheadSelect } from "cockpit-components-typeahead-select";
10 :
11 125 : 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 125 : export class FileAutoComplete extends React.Component<FileAutoCompleteProps, FileAutoCompleteState> {
36 125 : static defaultProps: Partial<FileAutoCompleteProps> = {
37 125 : isOptionCreatable: false,
38 125 : onlyDirectories: false,
39 2 : onChange: () => '',
40 125 : };
41 :
42 : allowFilesUpdate: boolean;
43 : debouncedChange: (value: string) => void;
44 :
45 3 : constructor(props: FileAutoCompleteProps) {
46 3 : super(props);
47 3 : this.state = {
48 3 : directory: '', // The current directory we list files/dirs from
49 3 : displayFiles: [],
50 3 : value: this.props.value || null,
51 3 : };
52 :
53 3 : this.allowFilesUpdate = true;
54 3 : this.clearSelection = this.clearSelection.bind(this);
55 :
56 3 : this.debouncedChange = debounce(300, this.onPathChange);
57 3 : }
58 :
59 3 : onPathChange = (value: string) => {
60 3 : if (!value) {
61 3 : this.clearSelection();
62 3 : return;
63 3 : }
64 :
65 0 : const cb = (dirPath: string) => this.updateFiles(dirPath == '' ? '/' : dirPath);
66 :
67 3 : let path = value;
68 3 : if (value.lastIndexOf('/') == value.length - 1)
69 3 : path = value.slice(0, value.length - 1);
70 :
71 3 : const match = this.state.displayFiles
72 1 : .find(entry => (entry.type == 'directory' && entry.path == path + '/') || (entry.type == 'file' && entry.path == path));
73 :
74 1 : 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 1 : 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 1 : if (match.type == 'directory' && !isPrefix)
82 1 : cb(match.path);
83 : else
84 1 : this.setState({ value: match.path });
85 1 : } 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 3 : const parentDir = value.slice(0, value.lastIndexOf('/'));
89 :
90 3 : if (parentDir + '/' != this.state.directory) {
91 3 : return this.updateFiles(parentDir + '/');
92 3 : }
93 3 : }
94 3 : };
95 :
96 3 : componentDidMount() {
97 3 : this.onPathChange(this.state.value || '');
98 3 : }
99 :
100 1 : componentWillUnmount() {
101 1 : this.allowFilesUpdate = false;
102 1 : }
103 :
104 3 : updateFiles(path: string) {
105 3 : if (this.state.directory == path)
106 3 : return;
107 :
108 3 : const channel = cockpit.channel({
109 3 : payload: "fslist1",
110 3 : path,
111 3 : superuser: this.props.superuser,
112 3 : watch: false,
113 3 : });
114 3 : const results: FileEntry[] = [];
115 :
116 3 : channel.addEventListener("ready", () => {
117 3 : this.finishUpdate(results, null, path);
118 3 : });
119 :
120 3 : channel.addEventListener("close", (_ev, data) => {
121 3 : this.finishUpdate(results, data.message as string | null, path);
122 3 : });
123 :
124 3 : channel.addEventListener("message", (_ev, data) => {
125 3 : const item = JSON.parse(data);
126 3 : if (item && item.path && item.event == 'present' &&
127 0 : (!this.props.onlyDirectories || item.type == 'directory')) {
128 3 : item.path = item.path + (item.type == 'directory' ? '/' : '');
129 3 : results.push(item);
130 3 : }
131 3 : });
132 3 : }
133 :
134 3 : finishUpdate(results: FileEntry[], error: string | null, directory: string) {
135 3 : if (!this.allowFilesUpdate)
136 3 : return;
137 3 : results = results.sort((a, b) => a.path.localeCompare(b.path));
138 :
139 3 : const listItems: FileEntry[] = results.map(file => ({
140 3 : type: file.type,
141 0 : path: (directory == '' ? '/' : directory) + file.path
142 3 : }));
143 :
144 3 : if (directory) {
145 3 : listItems.unshift({
146 3 : type: "directory",
147 3 : path: directory
148 3 : });
149 3 : }
150 :
151 3 : if (error || !this.state.value)
152 3 : this.props.onChange('', error);
153 :
154 3 : if (!error)
155 3 : this.setState({ displayFiles: listItems, directory });
156 3 : this.setState({
157 3 : error,
158 3 : });
159 3 : }
160 :
161 3 : clearSelection() {
162 3 : this.updateFiles("/");
163 3 : this.setState({ value: null });
164 3 : this.props.onChange('', null);
165 3 : }
166 :
167 3 : render() {
168 2 : const placeholder = this.props.placeholder || _("Path to file");
169 :
170 3 : const selectOptions = this.state.displayFiles
171 3 : .map(option => ({ value: option.path, content: option.path, className: option.type }));
172 :
173 3 : return (
174 3 : <TypeaheadSelect toggleProps={{ id: this.props.id }}
175 3 : isScrollable
176 3 : onInputChange={this.debouncedChange}
177 3 : placeholder={placeholder}
178 3 : noOptionsAvailableMessage={this.state.error || _("No such file or directory")}
179 3 : noOptionsFoundMessage={this.state.error || _("No such file or directory")}
180 1 : 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 1 : if (isOpen)
186 1 : this.onPathChange(this.state.value || '');
187 1 : }}
188 3 : selected={this.state.value}
189 3 : selectedIsTrusted
190 1 : onSelect={(_, value) => {
191 1 : const path = String(value);
192 1 : this.setState({ value: path });
193 1 : this.onPathChange(path);
194 1 : this.props.onChange(path, null);
195 1 : }}
196 3 : onClearSelection={this.clearSelection}
197 3 : isCreatable={this.props.isOptionCreatable}
198 0 : createOptionMessage={val => cockpit.format(_("Create $0"), val)}
199 3 : selectOptions={selectOptions} />
200 : );
201 3 : }
202 125 : }
|