Strip whitespace from found lines to prevent listview rows from being two lines tall.
[rox-find.git] / filefind.py
blob7d213bf13a33056e7b600c80fba43bb37a220fc9
1 """
2 filefind.py
3 A Find in Files Utility for the ROX Desktop.
5 Copyright 2005 Kenneth Hayber <ken@hayber.us>
6 All rights reserved.
8 This program is free software; you can redistribute it and/or modify
9 it under the terms of the GNU General Public License as published by
10 the Free Software Foundation; either version 2 of the License.
12 This program is distributed in the hope that it will be useful
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 GNU General Public License for more details.
17 You should have received a copy of the GNU General Public License
18 along with this program; if not, write to the Free Software
19 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20 """
22 from __future__ import generators
24 import rox, os, sys, popen2, signal
25 from rox import filer, Menu, tasks, basedir, mime
27 import gtk, os, sys, signal, re, string, socket, time, popen2, Queue, pango, gobject
30 APP_NAME = 'Find'
31 APP_SITE = 'hayber.us'
32 APP_PATH = rox.app_dir
34 MAX_HISTORY = 10
36 #Options.xml processing
37 from rox.options import Option
38 rox.setup_app_options(APP_NAME, site=APP_SITE)
39 Menu.set_save_name(APP_NAME, site=APP_SITE)
41 OPT_FIND_CMD = Option('find_cmd', 'find "$P" $R -name "$F" -exec grep -Hn $C $B "$T" "{}" \;')
42 OPT_EDIT_CMD = Option('edit_cmd', None)
44 OPT_MATCH_CASE = Option('match_case', True)
45 OPT_MATCH_WORDS = Option('match_words', False)
46 OPT_RECURSE_DIRS = Option('recurse_dirs', True)
47 OPT_IGNORE_BIN = Option('ignore_binary', True)
49 OPT_MATCH_CASE_ON = Option('match_case_on', '')
50 OPT_MATCH_CASE_OFF = Option('match_case_off', '-i')
52 OPT_MATCH_WORDS_ON = Option('match_words_on', '-w')
53 OPT_MATCH_WORDS_OFF = Option('match_words_off', '')
55 OPT_RECURSE_DIRS_ON = Option('recurse_dirs_on', '')
56 OPT_RECURSE_DIRS_OFF = Option('recurse_dirs_off', '-maxdepth 1')
58 OPT_IGNORE_BIN_ON = Option('ignore_binary_on', '-I')
59 OPT_IGNORE_BIN_OFF = Option('ignore_binary_off', '')
62 rox.app_options.notify()
64 #if you don't like the ComboBox, but want history support
65 #you can hardcode this to False (requires gtk 2.4+)
66 use_combo_box = hasattr(gtk, 'ComboBox')
68 if use_combo_box:
69 _entryClass = gtk.ComboBoxEntry
70 else:
71 _entryClass = gtk.Entry
73 class EntryThing(_entryClass):
74 '''This class does two things.
75 1) it wraps gtk.Entry | gtk.ComboBoxEntry for backwards compatibility
76 2) it adds history support via the ComboBox or EntryCompletion
77 '''
78 def __init__(self, history=None):
79 self.history = history
80 self.liststore = gtk.ListStore(gobject.TYPE_STRING)
81 if use_combo_box:
82 _entryClass.__init__(self, self.liststore, 0)
83 self.entry = self.child
84 else:
85 _entryClass.__init__(self)
86 self.entry = self
87 try: #for gtk < 2.4 compatibility and in case nothing is saved yet
88 completion = gtk.EntryCompletion()
89 self.set_completion(completion)
90 completion.set_model(self.liststore)
91 completion.set_text_column(0)
92 except:
93 pass
95 self.load()
97 def __del__(self):
98 '''This is supposed to write the history out upon exit,
99 but it never gets called!!!
101 self.write()
103 def get_text(self):
104 if use_combo_box:
105 return self.child.get_text()
106 else:
107 return _entryClass.get_text(self)
109 def set_text(self, text):
110 self.add_text(text)
111 if use_combo_box:
112 index = self.find_text(text)
113 if index != None:
114 self.set_active(index)
115 else:
116 _entryClass.set_text(self, text)
118 def find_text(self, text):
119 '''Check history to see if text already exists, return index or None'''
120 if not text: return
121 try:
122 item = self.liststore.get_iter_first()
123 index = 0
124 while item:
125 old_item = self.liststore.get_value(item, 0)
126 if old_item == text:
127 return index
128 item = self.liststore.iter_next(item)
129 index += 1
130 return None
131 except:
132 return None
134 def add_text(self, text):
135 '''Add item to history (if not duplicate)'''
136 if not text: return
137 if self.find_text(text) != None: return
138 try:
139 n = len(self.liststore)
140 if n >= MAX_HISTORY:
141 self.liststore.remove(self.liststore.get_iter((n-1,)))
142 self.liststore.insert(0, [text])
143 except:
144 pass
146 def write(self):
147 '''Write history to file in config directory'''
148 if not self.history:
149 return
151 def func(model, path, item, history_file):
152 history_file.write(model.get_value(item, 0)+'\n')
154 try:
155 save_dir = basedir.save_config_path(APP_SITE, APP_NAME)
156 history_file = file(os.path.join(save_dir, self.history), 'w')
157 self.liststore.foreach(func, history_file)
158 except:
159 pass
161 def load(self):
162 '''Read history from file and add to liststore'''
163 if not self.history:
164 return
166 try:
167 save_dir = basedir.save_config_path(APP_SITE, APP_NAME)
168 history_file = file(os.path.join(save_dir, self.history), 'r')
169 lines = history_file.readlines()
170 for x in lines:
171 self.liststore.append([x[:-1]]) #remove trailing newline
172 history_file.close()
173 except:
174 pass
176 RESPONSE_FIND = 1
177 RESPONSE_CLEAR = 2
178 RESPONSE_PREFS = 3
181 class FindWindow(rox.Dialog):
182 '''A Find in Files Utility:
183 Calls external search (e.g. find | grep) tool and parses output.
184 Found files and the matching text are displayed in a list.
185 Activating items in the list opens a Text Editor, optionally jumping to
186 the specific line of text.
188 find_process = None
189 updating_button_state = False # Ignore events
190 cancel = False
191 tree_node = None
193 def __init__(self, in_path=None, in_text=None, in_files=None):
194 rox.Dialog.__init__(self)
195 self.set_has_separator(False)
196 self.set_title(APP_NAME)
197 self.set_default_size(550, 500)
199 self.selected = False
200 self.path = ''
201 self.what = ''
202 self.where = ''
204 self.add_button(gtk.STOCK_CLEAR, RESPONSE_CLEAR)
205 self.add_button(gtk.STOCK_CLOSE, gtk.RESPONSE_CANCEL) # Must be Cancel for Escape to work
206 self.add_button(gtk.STOCK_HELP, gtk.RESPONSE_HELP)
207 self.find_btn = gtk.ToggleButton(gtk.STOCK_FIND)
208 self.find_btn.set_use_stock(True)
209 self.find_btn.set_flags(gtk.CAN_DEFAULT)
210 self.add_action_widget(self.find_btn, RESPONSE_FIND)
212 self.set_default_response(RESPONSE_FIND)
213 def resp(box, action):
214 if self.updating_button_state: return
216 if action == gtk.RESPONSE_CANCEL:
217 self.close()
218 elif action == RESPONSE_CLEAR:
219 self.clear()
220 elif action == RESPONSE_FIND:
221 if self.find_process is None:
222 self.start_find()
223 else:
224 self.cancel_find()
225 elif action == gtk.RESPONSE_HELP:
226 filer.open_dir(os.path.join(rox.app_dir, 'Help'))
227 self.connect('response', resp)
228 self.connect('delete_event', self.close)
230 self.set_response_sensitive(RESPONSE_CLEAR, False)
232 # Create layout, pack and show widgets
233 table = gtk.Table(5, 2, False)
234 x_pad = 2
235 y_pad = 1
237 path = EntryThing('path')
238 table.attach(gtk.Label(_('Path')), 0, 1, 2, 3, 0, 0, 4, y_pad)
239 table.attach(path, 1, 2, 2, 3, gtk.EXPAND|gtk.FILL, 0, x_pad, y_pad)
240 if hasattr(gtk, 'FileChooserDialog'):
241 self.browse = gtk.Button(label='...')
242 self.browse.connect('clicked', self.browser, path)
243 table.attach(self.browse, 2, 3, 2, 3, 0, 0, x_pad, y_pad)
245 what = EntryThing('pattern')
246 path.entry.connect('activate', lambda p: what.grab_focus())
247 table.attach(gtk.Label(_('Pattern')), 0, 1, 3, 4, 0, 0, 4, y_pad)
248 table.attach(what, 1, 2, 3, 4, gtk.EXPAND|gtk.FILL, 0, x_pad, y_pad)
250 where = EntryThing('files')
251 what.entry.connect('activate', lambda p: where.grab_focus())
252 where.entry.set_activates_default(True)
253 table.attach(gtk.Label(_('Files')), 0, 1, 4, 5, 0, 0, 4, y_pad)
254 table.attach(where, 1, 2, 4, 5, gtk.EXPAND|gtk.FILL, 0, x_pad, y_pad)
256 hbox1 = gtk.HBox()
258 self.match_case = gtk.CheckButton(label=_('Match case'))
259 self.match_case.set_active(bool(OPT_MATCH_CASE.int_value))
260 hbox1.pack_start(self.match_case,False, False, 5)
262 self.match_words = gtk.CheckButton(label=_('Match whole words'))
263 self.match_words.set_active(bool(OPT_MATCH_WORDS.int_value))
264 hbox1.pack_start(self.match_words,False, False, 5)
266 self.ignore_binary = gtk.CheckButton(label=_('Ignore binary files'))
267 self.ignore_binary.set_active(bool(OPT_IGNORE_BIN.int_value))
268 hbox1.pack_start(self.ignore_binary, False, False, 5)
270 hbox2 = gtk.HBox()
272 self.recurse_dirs = gtk.CheckButton(label=_('Search subdirectories'))
273 self.recurse_dirs.set_active(bool(OPT_RECURSE_DIRS.int_value))
274 hbox2.pack_start(self.recurse_dirs,False, False, 5)
276 prefs_btn = gtk.Button(stock = gtk.STOCK_PREFERENCES)
277 hbox2.pack_start(prefs_btn,False, False, 5)
278 prefs_btn.connect('clicked', self.edit_options)
280 swin = gtk.ScrolledWindow()
281 swin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
282 swin.set_shadow_type(gtk.SHADOW_IN)
284 self.store = gtk.TreeStore(str, int, str)
285 view = gtk.TreeView(self.store)
286 self.view = view
287 swin.add(view)
288 view.set_rules_hint(False)
290 # def func(model, iter, data):
291 # if len(model.get_path(iter)) < 2:
292 # return True
293 # else:
294 # return False
295 # view.set_row_separator_func(func, None)
297 cell = gtk.CellRendererText()
298 try: #for pre gtk 2.6.0 support
299 cell.set_property('ellipsize_set', True)
300 cell.set_property('ellipsize', pango.ELLIPSIZE_START)
301 except: pass
302 self.filename_column = gtk.TreeViewColumn(_('Filename'), cell, text = 0)
303 view.append_column(self.filename_column)
304 self.filename_column.set_resizable(True)
305 self.filename_column.set_reorderable(True)
306 self.filename_column.set_min_width(130)
308 cell = gtk.CellRendererText()
309 column = gtk.TreeViewColumn(_('Line'), cell, text = 1)
310 view.append_column(column)
311 column.set_resizable(True)
312 column.set_reorderable(True)
314 cell = gtk.CellRendererText()
315 column = gtk.TreeViewColumn(_('Text'), cell, text = 2)
316 view.append_column(column)
317 column.set_resizable(True)
318 column.set_reorderable(True)
320 view.connect('row-activated', self.activate)
321 self.selection = view.get_selection()
322 self.selection.connect('changed', self.set_selection)
324 vbox = self.vbox
325 vbox.set_spacing(5)
326 vbox.pack_start(table, False, False)
327 vbox.pack_start(hbox1, False, False)
328 vbox.pack_start(hbox2, False, False)
329 vbox.pack_start(swin, True, True)
330 vbox.show_all()
332 what.connect('changed', self.entry_changed)
333 where.connect('changed', self.entry_changed)
334 path.connect('changed', self.entry_changed)
336 self.path_entry = path
337 self.what_entry = what
338 self.where_entry = where
340 if in_path: path.set_text(in_path)
341 if in_text: what.set_text(in_text)
342 if in_files: where.set_text(in_files)
344 self.set_sensitives()
347 def start_find(self, *args):
348 '''Execute the find command after applying optional paramters'''
349 self.view.grab_focus() # Take focus from Find button!
350 self.cancel = False
352 self.path_entry.add_text(self.path)
353 self.what_entry.add_text(self.what)
354 self.where_entry.add_text(self.where)
356 self.tree_node = self.store.append(None)
357 self.store.set(self.tree_node,
358 0, os.path.join(self.path, self.where),
359 2, self.what)
360 self.view.set_cursor(self.store.get_path(self.tree_node))
362 path = os.path.expanduser(self.path)
364 cmd = OPT_FIND_CMD.value
365 #long options (deprecated)
366 cmd = string.replace(cmd, '$Path', path)
367 cmd = string.replace(cmd, '$Files', self.where)
368 cmd = string.replace(cmd, '$Text', self.what)
369 #short options
370 cmd = string.replace(cmd, '$P', path)
371 cmd = string.replace(cmd, '$F', self.where)
372 cmd = string.replace(cmd, '$T', self.what)
374 cmd = string.replace(cmd, '$C', [OPT_MATCH_CASE_OFF.value,
375 OPT_MATCH_CASE_ON.value]
376 [self.match_case.get_active()])
378 cmd = string.replace(cmd, '$W', [OPT_MATCH_WORDS_OFF.value,
379 OPT_MATCH_WORDS_ON.value]
380 [self.match_words.get_active()])
382 cmd = string.replace(cmd, '$B', [OPT_IGNORE_BIN_OFF.value,
383 OPT_IGNORE_BIN_ON.value]
384 [self.ignore_binary.get_active()])
386 cmd = string.replace(cmd, '$R', [OPT_RECURSE_DIRS_OFF.value,
387 OPT_RECURSE_DIRS_ON.value]
388 [self.recurse_dirs.get_active()])
390 self.find_process = popen2.Popen3(cmd)
391 tasks.Task(self.get_status(self.find_process))
392 self.set_sensitives()
395 def cancel_find(self, *args):
396 self.cancel = True
397 os.kill(self.find_process.pid, signal.SIGKILL)
400 def clear(self, *args):
401 self.store.clear()
402 self.selected = False
403 self.set_sensitives()
406 def close(self, *args):
407 if self.find_process is not None:
408 self.cancel_find()
409 self.path_entry.write()
410 self.what_entry.write()
411 self.where_entry.write()
412 self.destroy()
415 def get_status(self, thing):
416 '''Parse the ouput of the find command and fill the listbox.'''
417 outfile = thing.fromchild
418 results = None
419 while True:
420 blocker = tasks.InputBlocker(outfile)
421 yield blocker
422 if self.cancel:
423 self.cancel = False
424 self.find_process = None
425 return
426 line = outfile.readline()
427 line = line.rstrip()
428 if line:
429 results = True
430 self.set_sensitives()
431 iter = self.store.append(self.tree_node)
432 self.view.expand_row(self.store.get_path(self.tree_node), False)
433 try:
434 (filename, lineno, text) = string.split(line, ':', 2)
435 self.store.set(iter, 0, filename, 1, int(lineno), 2, text[:-1])
436 except:
437 self.store.set(iter, 2, line[:-1])
438 else:
439 code = thing.wait()
440 self.find_process = None
441 if code:
442 results = False
443 rox.info(_('There was a problem with this search'))
444 break
446 self.set_sensitives()
447 if results is None:
448 rox.info(_('Your search returned no results'))
451 def entry_changed(self, button):
452 self.path = self.path_entry.get_text()
453 self.what = self.what_entry.get_text()
454 self.where = self.where_entry.get_text()
455 self.set_sensitives()
458 def set_sensitives(self):
459 if self.find_process or (len(self.what) and len(self.where) and len(self.path)):
460 self.find_btn.set_sensitive(True)
461 else:
462 self.find_btn.set_sensitive(False)
464 self.set_response_sensitive(RESPONSE_CLEAR, bool(len(self.store)))
466 self.updating_button_state = True
467 self.find_btn.set_active(self.find_process is not None)
468 self.updating_button_state = False
471 def browser(self, button, path_widget):
472 browser = gtk.FileChooserDialog(title=_('Select folder'), buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OPEN, gtk.RESPONSE_ACCEPT))
473 if not len(self.path):
474 self.path = os.path.expanduser('~')
475 browser.set_current_folder(self.path)
476 browser.set_action(gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
477 if browser.run() != gtk.RESPONSE_CANCEL:
478 try:
479 self.path = browser.get_filename()
480 path_widget.set_text(self.path)
481 except:
482 rox.report_exception()
483 browser.hide()
486 def set_selection(self, *args):
487 self.selected = True
488 self.set_sensitives()
491 def activate(self, view, path, column):
492 '''Launch Editor for selected file/text'''
494 def get_type_handler(mime_type, handler_type = 'MIME-types'):
495 """Lookup the ROX-defined run action for a given mime type.
496 mime_type is an object returned by lookup().
497 handler_type is a config directory leaf (e.g.'MIME-types')."""
499 handler = basedir.load_first_config('rox.sourceforge.net', handler_type,
500 mime_type.media + '_' + mime_type.subtype)
501 if not handler:
502 # Fall back to the base handler if no subtype handler exists
503 handler = basedir.load_first_config('rox.sourceforge.net', handler_type,
504 mime_type.media)
505 return handler
507 if not path: return True
509 # Expand/Collapse search section rows
510 if len(path) < 2:
511 if self.view.row_expanded(path):
512 self.view.collapse_row(path)
513 else:
514 self.view.expand_row(path, False)
515 return True
517 model = view.get_model()
518 filename = model[path][0]
520 event = gtk.get_current_event()
522 if column is self.filename_column and event and event.type != gtk.gdk.KEY_PRESS:
523 filer.show_file(filename)
524 else:
525 line = model[path][1]
527 if len(OPT_EDIT_CMD.value):
528 cmd = OPT_EDIT_CMD.value
529 cmd = string.replace(cmd, '$File', filename)
530 cmd = string.replace(cmd, '$Line', str(line))
531 popen2.Popen4(cmd)
532 else: #use the ROX defined text handler
533 mime_type = rox.mime.lookup('text/plain')
534 handler = get_type_handler(mime_type)
535 handler_appdir = os.path.join(handler, 'AppRun')
536 if os.path.isdir(handler) and os.path.isfile(handler_appdir):
537 handler = handler_appdir
538 if handler:
539 popen2.Popen4('%s "%s"' % (handler, filename))
540 else:
541 rox.info(_('There is no run action defined for text files!'))
542 return True
545 def button_press(self, text, event):
546 '''Popup menu handler'''
547 if event.button != 3:
548 return 0
549 self.menu.popup(self, event)
550 return 1
553 def edit_options(self, *args):
554 '''Show Options dialog'''
555 rox.edit_options()
558 def get_options(self):
559 '''Get changed Options'''
560 pass